{"config":{"lang":["en"],"separator":"[\\s\\u200b\\-_,:!=\\[\\]()\"`/]+|\\.(?!\\d)|&[lg]t;|(?!\\b)(?=[A-Z][a-z])","pipeline":["stopWordFilter"],"fields":{"title":{"boost":1000.0},"text":{"boost":1.0},"tags":{"boost":1000000.0}}},"docs":[{"location":"","title":"Welcome to Changemaker Lite","text":"

Stop feeding your secrets to corporations. Own your political infrastructure.

Changemaker Lite V2 is Now Available

V2 is a complete architectural rebuild with a modern TypeScript stack, dual API design, React admin interface, and comprehensive feature modules. Production ready with security audit completed.

\u2192 Explore V2 Documentation \u2192 Quick Start Guide

"},{"location":"#changemaker-lite-v2","title":"Changemaker Lite V2","text":"

V2 is the recommended version for all new installations. It offers:

"},{"location":"#modern-architecture","title":"\u2728 Modern Architecture","text":""},{"location":"#comprehensive-features","title":"\ud83d\ude80 Comprehensive Features","text":""},{"location":"#production-ready","title":"\ud83d\udd12 Production Ready","text":""},{"location":"#complete-documentation","title":"\ud83d\udcda Complete Documentation","text":"

Explore V2 Documentation \u2192

"},{"location":"#changemaker-lite-v1-legacy","title":"Changemaker Lite V1 (Legacy)","text":"

V1 is Deprecated

V1 documentation is preserved below for reference. We strongly recommend migrating to V2 for improved performance, security, and features.

\u2192 View Migration Guide

"},{"location":"#quick-start-v1","title":"Quick Start (V1)","text":"

Get V1 running in minutes:

# Clone the repository\ngit clone https://gitea.bnkops.com/admin/changemaker.lite\ncd changemaker.lite\n\n# Configure environment\n./config.sh\n\n# Start all services\ndocker compose up -d\n\n# For production deployment with Cloudflare tunnels\n./start-production.sh\n
"},{"location":"#services","title":"Services","text":"

Changemaker Lite includes these essential services:

"},{"location":"#core-services","title":"Core Services","text":""},{"location":"#communication-automation","title":"Communication & Automation","text":""},{"location":"#data-development","title":"Data & Development","text":""},{"location":"#interactive-tools","title":"Interactive Tools","text":""},{"location":"#getting-started-v1","title":"Getting Started (V1)","text":"
  1. Setup: Run ./config.sh to configure your environment
  2. Launch: Start services with docker compose up -d
  3. Dashboard: Access the Homepage at http://localhost:3010
  4. Production: Deploy with Cloudflare tunnels using ./start-production.sh
"},{"location":"#project-structure","title":"Project Structure","text":"
changemaker.lite/\n\u251c\u2500\u2500 docker-compose.yml    # Service definitions\n\u251c\u2500\u2500 config.sh            # Configuration wizard\n\u251c\u2500\u2500 start-production.sh  # Production deployment script\n\u251c\u2500\u2500 mkdocs/              # Documentation source\n\u2502   \u251c\u2500\u2500 docs/            # Markdown files\n\u2502   \u2514\u2500\u2500 mkdocs.yml       # MkDocs configuration\n\u251c\u2500\u2500 configs/             # Service configurations\n\u2502   \u251c\u2500\u2500 homepage/        # Homepage dashboard config\n\u2502   \u251c\u2500\u2500 code-server/     # VS Code settings\n\u2502   \u2514\u2500\u2500 cloudflare/      # Tunnel configurations\n\u251c\u2500\u2500 map/                 # Map application\n\u2502   \u251c\u2500\u2500 app/             # Node.js application\n\u2502   \u251c\u2500\u2500 Dockerfile       # Container definition\n\u2502   \u2514\u2500\u2500 .env             # Map configuration\n\u2514\u2500\u2500 assets/              # Shared assets\n    \u251c\u2500\u2500 images/          # Image files\n    \u251c\u2500\u2500 icons/           # Service icons\n    \u2514\u2500\u2500 uploads/         # Listmonk uploads\n
"},{"location":"#key-features","title":"Key Features","text":""},{"location":"#system-requirements","title":"System Requirements","text":""},{"location":"#learn-more-v1","title":"Learn More (V1)","text":""},{"location":"blog/2025/07/03/blog-1/","title":"Blog 1","text":"

Hello! Just putting something up here because, well, gosh darn, feels like the right thing to do.

Making swift progress. Can now write things fast as heck lad.

"},{"location":"blog/2025/07/10/2/","title":"2","text":"

Wow. Big build day. Added (admittedly still buggy) shifts support to the system. Power did it in a day.

Other updates recently include:

Need to make more content about how to use the system in general too.

"},{"location":"blog/2025/08/01/3/","title":"3","text":"

Alrighty yall, it was a wild month of development, and we have a lot to cover! Here\u2019s the latest on Changemaker Lite, including our new landing page, major updates to the map application, and a comprehensive overview of all changes made in the last month.

Campaigning is going! We have candidates working the system in the field, and we\u2019re excited to see how it performs in real-world scenarios.

"},{"location":"blog/2025/08/01/3/#monthly-development-report-august-2025","title":"Monthly Development Report \u2013 August 2025","text":""},{"location":"blog/2025/08/01/3/#git-change-summary-julyaugust-2025","title":"Git Change Summary (July\u2013August 2025)","text":"

Below is a summary of all changes pushed to git in the last month:

For a detailed commit log, see git-report.txt.

"},{"location":"blog/2025/08/01/3/#overview-of-landerhtml","title":"Overview of lander.html","text":"

The lander.html file is a modern, responsive landing page for Changemaker Lite, featuring:

The page is styled with CSS variables for easy theming and includes scripts for search, theme switching, and smooth scrolling.

"},{"location":"blog/2025/08/01/3/#new-features-in-map-readmemd","title":"New Features in Map (README.md)","text":"

The map application has received significant upgrades:

API Endpoints: Comprehensive REST API for locations, shifts, authentication, admin, and geocoding, all with rate limiting and security features.

Database Schema: Auto-created tables for locations, users, settings, shifts, and signups, with detailed field definitions.

For more details, see the full README.md and explore the live application.

"},{"location":"blog/2025/09/24/4/","title":"4","text":"

Okay! Wow! Its been nearly 2 months since I wrote a blog update for this system.

We have pushed out influence as a beta product, and will be pushing it out to get feedback from real users over the next month.

Our campaign software Map was also used by a real campaign for the first time, and we have some great feedback to incorporate into the system.

"},{"location":"blog/2025/09/24/4/#what-weve-built-since-august","title":"What We've Built Since August","text":"

Here's a quick rundown of everything we've committed to the codebase over the past three months:

"},{"location":"blog/2025/09/24/4/#influence-app-major-launch","title":"Influence App - Major Launch","text":""},{"location":"blog/2025/09/24/4/#map-app-production-ready","title":"Map App - Production Ready","text":""},{"location":"blog/2025/09/24/4/#infrastructure-devops","title":"Infrastructure & DevOps","text":"

The velocity has been incredible - we went from concept to production with Influence in just a few weeks, and Map has evolved into a robust campaigning tool that's battle-tested in real elections. Looking forward to incorporating user feedback and continuing to iterate!

"},{"location":"how%20to/canvass/","title":"Canvas","text":"

This is BNKops canvassing how to! In the following document, you will find all sorts of tips and tricks for door knocking, canvassing, and using the BNKops canvassing app.

"},{"location":"phil/","title":"Philosophy: Your Secrets, Your Power, Your Movement","text":""},{"location":"phil/#the-question-that-changes-everything","title":"The Question That Changes Everything!","text":"

If you are a political actor, who do you trust with your secrets?

This isn't just a technical question\u2014it's the core political question of our time. Every email you send, every document you create, every contact list you build, every strategy you develop: where does it live? Who owns the servers? Who has the keys?

"},{"location":"phil/#the-corporate-extraction-machine","title":"The Corporate Extraction Machine","text":""},{"location":"phil/#how-they-hook-you","title":"How They Hook You","text":"

Corporate software companies have perfected the art of digital snake oil sales:

  1. Free Trials - They lure you in with \"free\" accounts
  2. Feature Creep - Essential features require paid tiers
  3. Data Lock-In - Your data becomes harder to export
  4. Price Escalation - $40/month becomes $750/month as you grow
  5. Surveillance Integration - Your organizing becomes their intelligence
"},{"location":"phil/#the-real-product","title":"The Real Product","text":"

You Are Not the Customer

If you're not paying for the product, you ARE the product. But even when you are paying, you're often still the product.

Corporate platforms don't make money from your subscription fees\u2014they make money from:

"},{"location":"phil/#the-bnkops-alternative","title":"The BNKops Alternative","text":""},{"location":"phil/#who-we-are","title":"Who We Are","text":"

BNKops is a cooperative based in amiskwaciy-w\u00e2skahikan (Edmonton, Alberta) on Treaty 6 territory. We're not a corporation\u2014we're a collective of skilled organizers, developers, and community builders who believe technology should serve liberation, not oppression.

"},{"location":"phil/#our-principles","title":"Our Principles","text":""},{"location":"phil/#liberation-first","title":"\ud83c\udff3\ufe0f\u200d\u26a7\ufe0f \ud83c\udff3\ufe0f\u200d\ud83c\udf08 \ud83c\uddf5\ud83c\uddf8 Liberation First","text":"

Technology that centers the most marginalized voices and fights for collective liberation. We believe strongly that the medium is the message; if you the use the medium of fascists, what does that say about your movement?

"},{"location":"phil/#community-over-profit","title":"\ud83e\udd1d Community Over Profit","text":"

We operate as a cooperative because we believe in shared ownership and democratic decision-making. No venture capitalists, no shareholders, no extraction.

"},{"location":"phil/#data-sovereignty","title":"\u26a1 Data Sovereignty","text":"

Your data belongs to you and your community. We build tools that let you own your digital infrastructure completely.

"},{"location":"phil/#security-culture","title":"\ud83d\udd12 Security Culture","text":"

Real security comes from community control, not corporate promises. We integrate security culture practices into our technology design.

"},{"location":"phil/#why-this-matters","title":"Why This Matters","text":"

When you control your technology infrastructure:

"},{"location":"phil/#the-philosophy-in-practice","title":"The Philosophy in Practice","text":""},{"location":"phil/#security-culture-meets-technology","title":"Security Culture Meets Technology","text":"

Traditional security culture asks: \"Who needs to know this information?\"

Digital security culture asks: \"Who controls the infrastructure where this information lives?\"

"},{"location":"phil/#community-technology","title":"Community Technology","text":"

We believe in community technology - tools that:

"},{"location":"phil/#prefigurative-politics","title":"Prefigurative Politics","text":"

The tools we use shape the movements we build. Corporate tools create corporate movements\u2014hierarchical, surveilled, and dependent. Community-controlled tools create community-controlled movements\u2014democratic, secure, and sovereign.

"},{"location":"phil/#common-questions","title":"Common Questions","text":""},{"location":"phil/#isnt-this-just-for-tech-people","title":"\"Isn't this just for tech people?\"","text":"

No. We specifically designed Changemaker Lite for organizers, activists, and movement builders who may not have technical backgrounds. Our philosophy is that everyone deserves digital sovereignty, not just people with computer science degrees.

This is not to say that you won't need to learn! These tools are just that; tools. They have no fancy or white-labeled marketing and are technical in nature. You will need to learn to use them, just as any worker needs to learn the power tools they use on the job.

"},{"location":"phil/#what-about-convenience","title":"\"What about convenience?\"","text":"

Corporate platforms are convenient because they've extracted billions of dollars from users to fund that convenience. When you own your tools, there's a learning curve\u2014but it's the same learning curve as learning to organize, learning to build power, learning to create change.

"},{"location":"phil/#cant-we-just-use-corporate-tools-carefully","title":"\"Can't we just use corporate tools carefully?\"","text":"

Would you hold your most sensitive organizing meetings in a room owned by your opposition? Would you store your membership lists in filing cabinets at a corporation that profits from surveillance? Digital tools are the same.

"},{"location":"phil/#what-about-security","title":"\"What about security?\"","text":"

Real security comes from community control, not corporate promises. When you control your infrastructure:

"},{"location":"phil/#the-surveillance-capitalism-trap","title":"The Surveillance Capitalism Trap","text":"

As Shoshana Zuboff documents in \"The Age of Surveillance Capitalism,\" we're living through a new form of capitalism that extracts value from human experience itself. Political movements are particularly valuable targets because:

"},{"location":"phil/#taking-action","title":"Taking Action","text":""},{"location":"phil/#start-where-you-are","title":"Start Where You Are","text":"

You don't have to replace everything at once. Start with one tool, one campaign, one project. Learn the technology alongside your organizing.

"},{"location":"phil/#build-community-capacity","title":"Build Community Capacity","text":"

The goal isn't individual self-sufficiency\u2014it's community technological sovereignty. Share skills, pool resources, learn together.

"},{"location":"phil/#connect-with-others","title":"Connect with Others","text":"

You're not alone in this. The free and open source software community, the digital security community, and the appropriate technology movement are all working on similar problems.

"},{"location":"phil/#remember-why","title":"Remember Why","text":"

This isn't about technology for its own sake. It's about building the infrastructure for the world we want to see\u2014where communities have power, where people control their own data, where technology serves liberation.

"},{"location":"phil/#resources-for-deeper-learning","title":"Resources for Deeper Learning","text":""},{"location":"phil/#essential-reading","title":"Essential Reading","text":""},{"location":"phil/#community-resources","title":"Community Resources","text":""},{"location":"phil/#technical-learning","title":"Technical Learning","text":"

This philosophy document is a living document. Contribute your thoughts, experiences, and improvements through the BNKops documentation platform.

"},{"location":"phil/cost-comparison/","title":"Cost Comparison: Corporation vs. Community","text":""},{"location":"phil/cost-comparison/#the-true-cost-of-corporate-dependency","title":"The True Cost of Corporate Dependency","text":"

When movements choose corporate software, they're not just paying subscription fees\u2014they're paying with their power, their privacy, and their future. Let's break down the real costs.

"},{"location":"phil/cost-comparison/#monthly-cost-analysis","title":"Monthly Cost Analysis","text":""},{"location":"phil/cost-comparison/#small-campaign-50-supporters-5000-emailsmonth","title":"Small Campaign (50 supporters, 5,000 emails/month)","text":"Service Category Corporate Solution Monthly Cost Changemaker Lite Monthly Cost Email Marketing Mailchimp $59/month Listmonk $0* Database & CRM Airtable Pro $240/month NocoDB $0* Website Hosting Squarespace $40/month Static Server $0* Documentation Notion Team $96/month MkDocs $0* Development GitHub Codespaces $87/month Code Server $0* Automation Zapier Professional $73/month n8n $0* File Storage Google Workspace $72/month PostgreSQL + Storage $0* Analytics Corporate tracking Privacy cost\u2020 Self-hosted $0* TOTAL $667/month $50/month

*Included in base Changemaker Lite hosting cost \u2020Privacy costs are incalculable but include surveillance, data sales, and community manipulation

"},{"location":"phil/cost-comparison/#medium-campaign-500-supporters-50000-emailsmonth","title":"Medium Campaign (500 supporters, 50,000 emails/month)","text":"Service Category Corporate Solution Monthly Cost Changemaker Lite Monthly Cost Email Marketing Mailchimp $299/month Listmonk $0* Database & CRM Airtable Pro $600/month NocoDB $0* Website Hosting Squarespace $65/month Static Server $0* Documentation Notion Team $240/month MkDocs $0* Development GitHub Codespaces $174/month Code Server $0* Automation Zapier Professional $146/month n8n $0* File Storage Google Workspace $144/month PostgreSQL + Storage $0* Analytics Corporate tracking Privacy cost\u2020 Self-hosted $0* TOTAL $1,668/month $75/month"},{"location":"phil/cost-comparison/#large-campaign-5000-supporters-500000-emailsmonth","title":"Large Campaign (5,000 supporters, 500,000 emails/month)","text":"Service Category Corporate Solution Monthly Cost Changemaker Lite Monthly Cost Email Marketing Mailchimp $1,499/month Listmonk $0* Database & CRM Airtable Pro $1,200/month NocoDB $0* Website Hosting Squarespace + CDN $120/month Static Server $0* Documentation Notion Team $480/month MkDocs $0* Development GitHub Codespaces $348/month Code Server $0* Automation Zapier Professional $292/month n8n $0* File Storage Google Workspace $288/month PostgreSQL + Storage $0* Analytics Corporate tracking Privacy cost\u2020 Self-hosted $0* TOTAL $4,227/month $150/month"},{"location":"phil/cost-comparison/#annual-savings-breakdown","title":"Annual Savings Breakdown","text":""},{"location":"phil/cost-comparison/#3-year-cost-comparison","title":"3-Year Cost Comparison","text":"Campaign Size Corporate Total Changemaker Total Savings Small $24,012 $1,800 $22,212 Medium $60,048 $2,700 $57,348 Large $152,172 $5,400 $146,772"},{"location":"phil/cost-comparison/#hidden-costs-of-corporate-software","title":"Hidden Costs of Corporate Software","text":""},{"location":"phil/cost-comparison/#what-you-cant-put-a-price-on","title":"What You Can't Put a Price On","text":""},{"location":"phil/cost-comparison/#privacy-violations","title":"Privacy Violations","text":""},{"location":"phil/cost-comparison/#political-manipulation","title":"Political Manipulation","text":""},{"location":"phil/cost-comparison/#movement-disruption","title":"Movement Disruption","text":""},{"location":"phil/cost-comparison/#the-changemaker-advantage","title":"The Changemaker Advantage","text":""},{"location":"phil/cost-comparison/#what-you-get-for-50-150month","title":"What You Get for $50-150/month","text":""},{"location":"phil/cost-comparison/#complete-infrastructure","title":"Complete Infrastructure","text":""},{"location":"phil/cost-comparison/#true-ownership","title":"True Ownership","text":""},{"location":"phil/cost-comparison/#community-support","title":"Community Support","text":""},{"location":"phil/cost-comparison/#the-compound-effect","title":"The Compound Effect","text":""},{"location":"phil/cost-comparison/#year-over-year-savings","title":"Year Over Year Savings","text":"

Corporate software costs grow exponentially: - Year 1: \"Starter\" pricing to hook you - Year 2: Feature limits force tier upgrades - Year 3: Usage growth triggers premium pricing - Year 4: Platform changes force expensive migrations - Year 5: Lock-in enables arbitrary price increases

Changemaker Lite costs grow linearly with actual infrastructure needs: - Year 1: Base infrastructure costs - Year 2: Modest increases for storage/bandwidth only - Year 3: Scale only with actual technical requirements - Year 4: Community-driven improvements at no extra cost - Year 5: Established infrastructure with declining per-user costs

"},{"location":"phil/cost-comparison/#10-year-projection","title":"10-Year Projection","text":"Year Corporate (Medium Campaign) Changemaker Lite Annual Savings 1 $20,016 $900 $19,116 2 $22,017 $900 $21,117 3 $24,219 $1,080 $23,139 4 $26,641 $1,080 $25,561 5 $29,305 $1,260 $28,045 6 $32,235 $1,260 $30,975 7 $35,459 $1,440 $34,019 8 $39,005 $1,440 $37,565 9 $42,905 $1,620 $41,285 10 $47,196 $1,620 $45,576 TOTAL $318,998 $12,600 $306,398"},{"location":"phil/cost-comparison/#calculate-your-own-savings","title":"Calculate Your Own Savings","text":""},{"location":"phil/cost-comparison/#current-corporate-costs-worksheet","title":"Current Corporate Costs Worksheet","text":"

Email Marketing: $____/month Database/CRM: $____/month Website Hosting: $____/month Documentation: $____/month Development Tools: $____/month Automation: $____/month File Storage: $____/month Other SaaS: $____/month

Monthly Total: $____ Annual Total: $____

Changemaker Alternative: $50-150/month Your Annual Savings: $____

"},{"location":"phil/cost-comparison/#beyond-the-numbers","title":"Beyond the Numbers","text":""},{"location":"phil/cost-comparison/#what-movements-do-with-their-savings","title":"What Movements Do With Their Savings","text":"

The money saved by choosing community-controlled technology doesn't disappear\u2014it goes directly back into movement building:

"},{"location":"phil/cost-comparison/#making-the-switch","title":"Making the Switch","text":""},{"location":"phil/cost-comparison/#transition-strategy","title":"Transition Strategy","text":"

You don't have to switch everything at once:

  1. Start with documentation - Move your knowledge base to MkDocs
  2. Add email infrastructure - Set up Listmonk for newsletters
  3. Build your database - Move contact management to NocoDB
  4. Automate connections - Use n8n to integrate everything
  5. Phase out corporate tools - Cancel subscriptions as you replicate functionality
"},{"location":"phil/cost-comparison/#investment-timeline","title":"Investment Timeline","text":""},{"location":"phil/cost-comparison/#roi-calculation","title":"ROI Calculation","text":"

Most campaigns recover their entire first-year investment in 60-90 days through subscription savings alone.

Ready to stop feeding your budget to corporate surveillance? Get started with Changemaker Lite today and take control of your digital infrastructure.

"},{"location":"v1/","title":"V1 Documentation (Deprecated)","text":"

V1 is Legacy

Changemaker Lite V1 is deprecated and no longer actively maintained. These docs are preserved for reference only.

"},{"location":"v1/#migrating-to-v2","title":"Migrating to V2","text":"

Changemaker Lite V2 is a complete architectural rebuild with significant improvements:

A quick test because why not.

"},{"location":"v1/#why-upgrade-to-v2","title":"Why Upgrade to V2?","text":"

Modern Stack - TypeScript throughout (V1 was JavaScript) - Prisma ORM (V1 used NocoDB REST API) - JWT auth (V1 used session cookies) - React admin (V1 used server-rendered HTML)

Better Performance - Direct database access (no NocoDB middleware) - Redis-backed caching and rate limiting - BullMQ job queues for async operations - Optimized queries with Prisma

Enhanced Security - Security audit completed (Feb 2026) - Password policy enforcement (12+ chars, complexity) - Refresh token rotation in transactions - XSS/injection prevention throughout - Rate limiting on all sensitive endpoints

New Features - Volunteer canvassing system with GPS tracking - Landing page builder (GrapesJS) - Email template management - Media library (video management) - Observability dashboard (Prometheus + Grafana) - NAR 2025 data import (Canadian electoral data)

View complete V2 documentation \u2192

"},{"location":"v1/#migration-guide","title":"Migration Guide","text":"

Ready to migrate? Follow our step-by-step guide:

\u2192 V1 to V2 Migration Guide

The migration guide covers:

  1. Breaking Changes - NocoDB \u2192 Prisma, API endpoint changes
  2. Data Migration - Export V1 data, transform, import to V2
  3. Configuration Changes - Environment variables, service names
  4. Feature Parity - V1 vs V2 feature comparison
"},{"location":"v1/#v1-documentation-archive","title":"V1 Documentation Archive","text":"

These docs are preserved for existing V1 installations:

"},{"location":"v1/#build-guides","title":"Build Guides","text":""},{"location":"v1/#services","title":"Services","text":""},{"location":"v1/#configuration","title":"Configuration","text":""},{"location":"v1/#manuals","title":"Manuals","text":""},{"location":"v1/#advanced","title":"Advanced","text":""},{"location":"v1/#v1-architecture-reference","title":"V1 Architecture (Reference)","text":"

V1 used a two-app architecture with NocoDB as the data layer:

Influence App (port 3333) - Express.js server with server-rendered HTML - NocoDB REST API for database operations - Session-based authentication (Redis) - Bull job queues for emails

Map App (port 3000) - Express.js server with Leaflet.js maps - NocoDB REST API for database operations - QR code generation - Volunteer shift management

Shared Infrastructure - NocoDB (data layer) - Redis (sessions, cache, queues) - PostgreSQL (via NocoDB) - Cloudflare tunnels

"},{"location":"v1/#support-for-v1","title":"Support for V1","text":"

V1 is no longer under active development. We recommend migrating to V2.

For critical V1 issues: - Check existing V1 documentation - Review V1 code in /influence and /map directories - Consider migrating to V2

Ready to upgrade? Start with the V2 Quick Start Guide \u2192

"},{"location":"v1/adv/","title":"Advanced Configurations","text":"

We are also publishing how BNKops does several advanced workflows. These include things like assembling hardware, how to manage a network, how to manage several changemakers simultaneously, and integrating AI.

"},{"location":"v1/adv/ansible/","title":"Setting Up Ansible with Tailscale for Remote Server Management","text":""},{"location":"v1/adv/ansible/#overview","title":"Overview","text":"

This guide walks you through setting up Ansible to manage remote servers (like ThinkCentre units) using Tailscale for secure networking. This approach provides reliable remote access without complex port forwarding or VPN configurations.

In plainer language; this allows you to manage several Changemaker nodes remotely. If you are a full time campaigner, this can enable you to manage several campaigns infrastructure from a central location while each user gets their own Changemaker box.

"},{"location":"v1/adv/ansible/#what-youll-learn","title":"What You'll Learn","text":""},{"location":"v1/adv/ansible/#prerequisites","title":"Prerequisites","text":""},{"location":"v1/adv/ansible/#part-1-initial-setup-on-master-node","title":"Part 1: Initial Setup on Master Node","text":""},{"location":"v1/adv/ansible/#1-create-ansible-directory-structure","title":"1. Create Ansible Directory Structure","text":"
# Create project directory\nmkdir ~/ansible_quickstart\ncd ~/ansible_quickstart\n\n# Create directory structure\nmkdir -p group_vars host_vars roles playbooks\n
"},{"location":"v1/adv/ansible/#2-install-ansible","title":"2. Install Ansible","text":"
sudo apt update\nsudo apt install ansible\n
"},{"location":"v1/adv/ansible/#3-generate-ssh-keys-if-not-already-done","title":"3. Generate SSH Keys (if not already done)","text":"
# Generate SSH key pair\nssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa\n\n# Display public key (save this for later)\ncat ~/.ssh/id_rsa.pub\n
"},{"location":"v1/adv/ansible/#part-2-target-node-setup-physical-access-required-initially","title":"Part 2: Target Node Setup (Physical Access Required Initially)","text":""},{"location":"v1/adv/ansible/#1-enable-ssh-on-target-node","title":"1. Enable SSH on Target Node","text":"

Access each target node physically (monitor + keyboard):

# Update system\nsudo apt update && sudo apt upgrade -y\n\n# Install and enable SSH\nsudo apt install openssh-server\nsudo systemctl enable ssh\nsudo systemctl start ssh\n\n# Check SSH status\nsudo systemctl status ssh\n

Note: If you get \"Unit ssh.service could not be found\", you need to install the SSH server first:

# Install OpenSSH server\nsudo apt install openssh-server\n\n# Then start and enable SSH\nsudo systemctl start ssh\nsudo systemctl enable ssh\n\n# Verify SSH is running and listening\nsudo ss -tlnp | grep :22\n

You should see SSH listening on port 22.

"},{"location":"v1/adv/ansible/#2-configure-ssh-key-authentication","title":"2. Configure SSH Key Authentication","text":"
# Create .ssh directory\nmkdir -p ~/.ssh\nchmod 700 ~/.ssh\n\n# Create authorized_keys file\nnano ~/.ssh/authorized_keys\n

Paste your public key from the master node, then:

# Set proper permissions\nchmod 600 ~/.ssh/authorized_keys\n
"},{"location":"v1/adv/ansible/#3-configure-ssh-security","title":"3. Configure SSH Security","text":"
# Edit SSH config\nsudo nano /etc/ssh/sshd_config\n

Ensure these lines are uncommented:

PubkeyAuthentication yes\nAuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2\n
# Restart SSH service\nsudo systemctl restart ssh\n
"},{"location":"v1/adv/ansible/#4-configure-firewall","title":"4. Configure Firewall","text":"
# Check firewall status\nsudo ufw status\n\n# Allow SSH through firewall\nsudo ufw allow ssh\n\n# Fix home directory permissions (required for SSH keys)\nchmod 755 ~/\n
"},{"location":"v1/adv/ansible/#part-3-test-local-ssh-connection","title":"Part 3: Test Local SSH Connection","text":"

Before proceeding with remote access, test SSH connectivity locally:

# From master node, test SSH to target\nssh username@<target-local-ip>\n

Common Issues and Solutions:

"},{"location":"v1/adv/ansible/#part-4-set-up-tailscale-for-remote-access","title":"Part 4: Set Up Tailscale for Remote Access","text":""},{"location":"v1/adv/ansible/#why-tailscale-over-alternatives","title":"Why Tailscale Over Alternatives","text":"

We initially tried Cloudflare Tunnels but encountered complexity with:

Tailscale is superior because:

"},{"location":"v1/adv/ansible/#1-install-tailscale-on-master-node","title":"1. Install Tailscale on Master Node","text":"
# Install Tailscale\ncurl -fsSL https://tailscale.com/install.sh | sh\n\n# Connect to Tailscale network\nsudo tailscale up\n

Follow the authentication URL to connect with your Google/Microsoft/GitHub account.

"},{"location":"v1/adv/ansible/#2-install-tailscale-on-target-nodes","title":"2. Install Tailscale on Target Nodes","text":"

On each target node:

# Install Tailscale\ncurl -fsSL https://tailscale.com/install.sh | sh\n\n# Connect to Tailscale network\nsudo tailscale up\n

Authenticate each device through the provided URL.

"},{"location":"v1/adv/ansible/#3-get-tailscale-ip-addresses","title":"3. Get Tailscale IP Addresses","text":"

On each machine:

# Get your Tailscale IP\ntailscale ip -4\n

Each device receives a persistent IP like 100.x.x.x.

"},{"location":"v1/adv/ansible/#part-5-configure-ansible","title":"Part 5: Configure Ansible","text":""},{"location":"v1/adv/ansible/#1-create-inventory-file","title":"1. Create Inventory File","text":"
# Create inventory.ini\ncd ~/ansible_quickstart\nnano inventory.ini\n

Content:

[thinkcenter]\ntc-node1 ansible_host=100.x.x.x ansible_user=your-username\ntc-node2 ansible_host=100.x.x.x ansible_user=your-username\n\n[all:vars]\nansible_ssh_private_key_file=~/.ssh/id_rsa\nansible_host_key_checking=False\n

Replace:

"},{"location":"v1/adv/ansible/#2-test-ansible-connectivity","title":"2. Test Ansible Connectivity","text":"
# Test connection to all nodes\nansible all -i inventory.ini -m ping\n

Expected output:

tc-node1 | SUCCESS => {\n    \"changed\": false,\n    \"ping\": \"pong\"\n}\n
"},{"location":"v1/adv/ansible/#part-6-create-and-run-playbooks","title":"Part 6: Create and Run Playbooks","text":""},{"location":"v1/adv/ansible/#1-simple-information-gathering-playbook","title":"1. Simple Information Gathering Playbook","text":"
mkdir -p playbooks\nnano playbooks/info-playbook.yml\n

Content:

---\n- name: Gather Node Information\n  hosts: all\n  tasks:\n    - name: Get system information\n      setup:\n\n    - name: Display basic system info\n      debug:\n        msg: |\n          Hostname: {{ ansible_hostname }}\n          Operating System: {{ ansible_distribution }} {{ ansible_distribution_version }}\n          Architecture: {{ ansible_architecture }}\n          Memory: {{ ansible_memtotal_mb }}MB\n          CPU Cores: {{ ansible_processor_vcpus }}\n\n    - name: Show disk usage\n      command: df -h /\n      register: disk_info\n\n    - name: Display disk usage\n      debug:\n        msg: \"Root filesystem usage: {{ disk_info.stdout_lines[1] }}\"\n\n    - name: Check uptime\n      command: uptime\n      register: uptime_info\n\n    - name: Display uptime\n      debug:\n        msg: \"System uptime: {{ uptime_info.stdout }}\"\n
"},{"location":"v1/adv/ansible/#2-run-the-playbook","title":"2. Run the Playbook","text":"
ansible-playbook -i inventory.ini playbooks/info-playbook.yml\n
"},{"location":"v1/adv/ansible/#part-7-advanced-playbook-example","title":"Part 7: Advanced Playbook Example","text":""},{"location":"v1/adv/ansible/#system-setup-playbook","title":"System Setup Playbook","text":"
nano playbooks/setup-node.yml\n

Content:

---\n- name: Setup ThinkCentre Node\n  hosts: all\n  become: yes\n  tasks:\n    - name: Update package cache\n      apt:\n        update_cache: yes\n\n    - name: Install essential packages\n      package:\n        name:\n          - htop\n          - vim\n          - curl\n          - git\n          - docker.io\n        state: present\n\n    - name: Add user to docker group\n      user:\n        name: \"{{ ansible_user }}\"\n        groups: docker\n        append: yes\n\n    - name: Create management directory\n      file:\n        path: /opt/management\n        state: directory\n        owner: \"{{ ansible_user }}\"\n        group: \"{{ ansible_user }}\"\n
"},{"location":"v1/adv/ansible/#troubleshooting-guide","title":"Troubleshooting Guide","text":""},{"location":"v1/adv/ansible/#ssh-issues","title":"SSH Issues","text":"

Problem: SSH connection hangs

Problem: Permission denied (publickey)

Problem: Bad owner or permissions on SSH config

chmod 600 ~/.ssh/config\n
"},{"location":"v1/adv/ansible/#ansible-issues","title":"Ansible Issues","text":"

Problem: Host key verification failed

Problem: Ansible command not found

sudo apt install ansible\n

Problem: Connection timeouts

"},{"location":"v1/adv/ansible/#tailscale-issues","title":"Tailscale Issues","text":"

Problem: Can't connect to Tailscale IP

"},{"location":"v1/adv/ansible/#scaling-to-multiple-nodes","title":"Scaling to Multiple Nodes","text":""},{"location":"v1/adv/ansible/#adding-new-nodes","title":"Adding New Nodes","text":"
  1. Install Tailscale on new node
  2. Set up SSH access (repeat Part 2)
  3. Add to inventory.ini:
[thinkcenter]\ntc-node1 ansible_host=100.125.148.60 ansible_user=bunker-admin\ntc-node2 ansible_host=100.x.x.x ansible_user=bunker-admin\ntc-node3 ansible_host=100.x.x.x ansible_user=bunker-admin\n
"},{"location":"v1/adv/ansible/#group-management","title":"Group Management","text":"
[webservers]\ntc-node1 ansible_host=100.x.x.x ansible_user=bunker-admin\ntc-node2 ansible_host=100.x.x.x ansible_user=bunker-admin\n\n[databases]\ntc-node3 ansible_host=100.x.x.x ansible_user=bunker-admin\n\n[all:vars]\nansible_ssh_private_key_file=~/.ssh/id_rsa\nansible_host_key_checking=False\n

Run playbooks on specific groups:

ansible-playbook -i inventory.ini -l webservers playbook.yml\n
"},{"location":"v1/adv/ansible/#best-practices","title":"Best Practices","text":""},{"location":"v1/adv/ansible/#security","title":"Security","text":""},{"location":"v1/adv/ansible/#organization","title":"Organization","text":"
ansible_quickstart/\n\u251c\u2500\u2500 inventory.ini\n\u251c\u2500\u2500 group_vars/\n\u251c\u2500\u2500 host_vars/\n\u251c\u2500\u2500 roles/\n\u2514\u2500\u2500 playbooks/\n    \u251c\u2500\u2500 info-playbook.yml\n    \u251c\u2500\u2500 setup-node.yml\n    \u2514\u2500\u2500 maintenance.yml\n
"},{"location":"v1/adv/ansible/#monitoring-and-maintenance","title":"Monitoring and Maintenance","text":"

Create regular maintenance playbooks:

- name: System maintenance\n  hosts: all\n  become: yes\n  tasks:\n    - name: Update all packages\n      apt:\n        upgrade: dist\n        update_cache: yes\n\n    - name: Clean package cache\n      apt:\n        autoclean: yes\n        autoremove: yes\n
"},{"location":"v1/adv/ansible/#alternative-approaches-we-considered","title":"Alternative Approaches We Considered","text":""},{"location":"v1/adv/ansible/#cloudflare-tunnels","title":"Cloudflare Tunnels","text":""},{"location":"v1/adv/ansible/#traditional-vpn","title":"Traditional VPN","text":""},{"location":"v1/adv/ansible/#ssh-reverse-tunnels","title":"SSH Reverse Tunnels","text":""},{"location":"v1/adv/ansible/#conclusion","title":"Conclusion","text":"

This setup provides:

The combination of Ansible + Tailscale is ideal for managing distributed infrastructure without the complexity of traditional VPN setups or the limitations of cloud-specific solutions.

"},{"location":"v1/adv/ansible/#quick-reference-commands","title":"Quick Reference Commands","text":"
# Check Tailscale status\ntailscale status\n\n# Test Ansible connectivity\nansible all -i inventory.ini -m ping\n\n# Run playbook on all hosts\nansible-playbook -i inventory.ini playbook.yml\n\n# Run playbook on specific group\nansible-playbook -i inventory.ini -l groupname playbook.yml\n\n# Run single command on all hosts\nansible all -i inventory.ini -m command -a \"uptime\"\n\n# SSH to node via Tailscale\nssh username@100.x.x.x\n
"},{"location":"v1/adv/vscode-ssh/","title":"Remote Development with VSCode over Tailscale","text":""},{"location":"v1/adv/vscode-ssh/#overview","title":"Overview","text":"

This guide describes how to set up Visual Studio Code for remote development on servers using the Tailscale network. This enables development directly on remote machines as if they were local, with full access to files, terminals, and debugging capabilities.

"},{"location":"v1/adv/vscode-ssh/#what-youll-learn","title":"What You'll Learn","text":""},{"location":"v1/adv/vscode-ssh/#prerequisites","title":"Prerequisites","text":""},{"location":"v1/adv/vscode-ssh/#verify-prerequisites","title":"Verify Prerequisites","text":"

Before starting, verify the setup:

# Check Tailscale connectivity\ntailscale status\n\n# Test SSH access\nssh <username>@<tailscale-ip>\n\n# Check VSCode is installed\ncode --version\n
"},{"location":"v1/adv/vscode-ssh/#part-1-install-and-configure-remote-ssh-extension","title":"Part 1: Install and Configure Remote-SSH Extension","text":""},{"location":"v1/adv/vscode-ssh/#1-install-the-remote-development-extensions","title":"1. Install the Remote Development Extensions","text":"

Option A: Install Remote Development Pack (Recommended)

  1. Open VSCode
  2. Press Ctrl+Shift+X (or Cmd+Shift+X on Mac)
  3. Search for \"Remote Development\"
  4. Install the Remote Development extension pack by Microsoft

This pack includes:

Option B: Install Individual Extension

  1. Search for \"Remote - SSH\"
  2. Install Remote - SSH by Microsoft
"},{"location":"v1/adv/vscode-ssh/#2-verify-installation","title":"2. Verify Installation","text":"

After installation, the following should be visible:

"},{"location":"v1/adv/vscode-ssh/#part-2-configure-ssh-connections","title":"Part 2: Configure SSH Connections","text":""},{"location":"v1/adv/vscode-ssh/#1-access-ssh-configuration","title":"1. Access SSH Configuration","text":"

Method A: Through VSCode

  1. Press Ctrl+Shift+P to open Command Palette
  2. Type \"Remote-SSH: Open SSH Configuration File...\"
  3. Select the SSH config file (usually the first option)

Method B: Direct File Editing

# Edit SSH config file directly\nnano ~/.ssh/config\n

"},{"location":"v1/adv/vscode-ssh/#2-add-server-configurations","title":"2. Add Server Configurations","text":"

Add servers to the SSH config file:

# Example Node\nHost node1\n    HostName <tailscale-ip>\n    User <username>\n    IdentityFile ~/.ssh/id_rsa\n    ForwardAgent yes\n    ServerAliveInterval 60\n    ServerAliveCountMax 3\n\n# Additional nodes (add as needed)\nHost node2\n    HostName <tailscale-ip>\n    User <username>\n    IdentityFile ~/.ssh/id_rsa\n    ForwardAgent yes\n    ServerAliveInterval 60\n    ServerAliveCountMax 3\n

Configuration Options Explained:

"},{"location":"v1/adv/vscode-ssh/#3-set-proper-ssh-key-permissions","title":"3. Set Proper SSH Key Permissions","text":"
# Ensure SSH config has correct permissions\nchmod 600 ~/.ssh/config\n\n# Verify SSH key permissions\nchmod 600 ~/.ssh/id_rsa\nchmod 644 ~/.ssh/id_rsa.pub\n
"},{"location":"v1/adv/vscode-ssh/#part-3-connect-to-remote-servers","title":"Part 3: Connect to Remote Servers","text":""},{"location":"v1/adv/vscode-ssh/#1-connect-via-command-palette","title":"1. Connect via Command Palette","text":"
  1. Press Ctrl+Shift+P
  2. Type \"Remote-SSH: Connect to Host...\"
  3. Select the server (e.g., node1)
  4. VSCode will open a new window connected to the remote server
"},{"location":"v1/adv/vscode-ssh/#2-connect-via-remote-explorer","title":"2. Connect via Remote Explorer","text":"
  1. Click the Remote Explorer icon in Activity Bar
  2. Expand SSH Targets
  3. Click the connect icon next to the server name
"},{"location":"v1/adv/vscode-ssh/#3-connect-via-quick-menu","title":"3. Connect via Quick Menu","text":"
  1. Click the remote indicator in bottom-left corner (looks like ><)
  2. Select \"Connect to Host...\"
  3. Choose the server from the list
"},{"location":"v1/adv/vscode-ssh/#4-first-connection-process","title":"4. First Connection Process","text":"

On first connection, VSCode will:

  1. Verify the host key (click \"Continue\" if prompted)
  2. Install VSCode Server on the remote machine (automatic)
  3. Open a remote window with access to the remote file system

Expected Timeline: - First connection: 1-3 minutes (installs VSCode Server) - Subsequent connections: 10-30 seconds

"},{"location":"v1/adv/vscode-ssh/#part-4-remote-development-environment-setup","title":"Part 4: Remote Development Environment Setup","text":""},{"location":"v1/adv/vscode-ssh/#1-open-remote-workspace","title":"1. Open Remote Workspace","text":"

Once connected:

# In the VSCode terminal (now running on remote server)\n# Navigate to the project directory\ncd /home/<username>/projects\n\n# Open current directory in VSCode\ncode .\n\n# Or open a specific project\ncode /opt/myproject\n
"},{"location":"v1/adv/vscode-ssh/#2-install-extensions-on-remote-server","title":"2. Install Extensions on Remote Server","text":"

Extensions must be installed separately on the remote server:

Essential Development Extensions:

  1. Python (Microsoft) - Python development
  2. GitLens (GitKraken) - Enhanced Git capabilities
  3. Docker (Microsoft) - Container development
  4. Prettier - Code formatting
  5. ESLint - JavaScript linting
  6. Auto Rename Tag - HTML/XML tag editing

To Install:

  1. Go to Extensions (Ctrl+Shift+X)
  2. Find the desired extension
  3. Click \"Install in SSH: node1\" (not local install)
"},{"location":"v1/adv/vscode-ssh/#3-configure-git-on-remote-server","title":"3. Configure Git on Remote Server","text":"
# In VSCode terminal (remote)\ngit config --global user.name \"<Full Name>\"\ngit config --global user.email \"<email@example.com>\"\n\n# Test Git connectivity\ngit clone https://github.com/<username>/<repo>.git\n
"},{"location":"v1/adv/vscode-ssh/#part-5-remote-development-workflows","title":"Part 5: Remote Development Workflows","text":""},{"location":"v1/adv/vscode-ssh/#1-file-management","title":"1. File Management","text":"

File Explorer:

File Transfer:

# Upload files to remote (from local terminal)\nscp localfile.txt <username>@<tailscale-ip>:/home/<username>/\n\n# Download files from remote\nscp <username>@<tailscale-ip>:/remote/path/file.txt ./local/path/\n

"},{"location":"v1/adv/vscode-ssh/#2-terminal-usage","title":"2. Terminal Usage","text":"

Integrated Terminal:

Common Remote Terminal Commands:

# Check system resources\nhtop\ndf -h\nfree -h\n\n# Install packages\nsudo apt update\nsudo apt install nodejs npm\n\n# Start services\nsudo systemctl start nginx\nsudo docker-compose up -d\n

"},{"location":"v1/adv/vscode-ssh/#3-port-forwarding","title":"3. Port Forwarding","text":"

Automatic Port Forwarding: VSCode automatically detects and forwards common development ports.

Manual Port Forwarding:

  1. Open Ports tab in terminal panel
  2. Click \"Forward a Port\"
  3. Enter port number (e.g., 3000, 8080, 5000)
  4. Access via http://localhost:port on the local machine

Example: Web Development

# Start a web server on remote (port 3000)\nnpm start\n\n# VSCode automatically suggests forwarding port 3000\n# Access at http://localhost:3000 on the local machine\n

"},{"location":"v1/adv/vscode-ssh/#4-debugging-remote-applications","title":"4. Debugging Remote Applications","text":"

Python Debugging:

// .vscode/launch.json on remote server\n{\n    \"version\": \"0.2.0\",\n    \"configurations\": [\n        {\n            \"name\": \"Python: Current File\",\n            \"type\": \"python\",\n            \"request\": \"launch\",\n            \"program\": \"${file}\",\n            \"console\": \"integratedTerminal\"\n        }\n    ]\n}\n

Node.js Debugging:

// .vscode/launch.json\n{\n    \"version\": \"0.2.0\",\n    \"configurations\": [\n        {\n            \"name\": \"Launch Program\",\n            \"type\": \"node\",\n            \"request\": \"launch\",\n            \"program\": \"${workspaceFolder}/app.js\"\n        }\n    ]\n}\n

"},{"location":"v1/adv/vscode-ssh/#part-6-advanced-configuration","title":"Part 6: Advanced Configuration","text":""},{"location":"v1/adv/vscode-ssh/#1-workspace-settings","title":"1. Workspace Settings","text":"

Create remote-specific settings:

// .vscode/settings.json (on remote server)\n{\n    \"python.defaultInterpreterPath\": \"/usr/bin/python3\",\n    \"terminal.integrated.shell.linux\": \"/bin/bash\",\n    \"files.autoSave\": \"afterDelay\",\n    \"editor.formatOnSave\": true,\n    \"remote.SSH.remotePlatform\": {\n        \"node1\": \"linux\"\n    }\n}\n
"},{"location":"v1/adv/vscode-ssh/#2-multi-server-management","title":"2. Multi-Server Management","text":"

Switch Between Servers:

  1. Click remote indicator (bottom-left)
  2. Select \"Connect to Host...\"
  3. Choose a different server

Compare Files Across Servers:

  1. Open file from server A
  2. Connect to server B in new window
  3. Open corresponding file
  4. Use \"Compare with...\" command
"},{"location":"v1/adv/vscode-ssh/#3-sync-configuration","title":"3. Sync Configuration","text":"

Settings Sync:

  1. Enable Settings Sync in VSCode
  2. Settings, extensions, and keybindings sync to remote
  3. Consistent experience across all servers
"},{"location":"v1/adv/vscode-ssh/#part-7-project-specific-setups","title":"Part 7: Project-Specific Setups","text":""},{"location":"v1/adv/vscode-ssh/#1-python-development","title":"1. Python Development","text":"
# On remote server\n# Create virtual environment\npython3 -m venv venv\nsource venv/bin/activate\n\n# Install packages\npip install flask django requests\n\n# VSCode automatically detects Python interpreter\n

VSCode Python Configuration:

// .vscode/settings.json\n{\n    \"python.defaultInterpreterPath\": \"./venv/bin/python\",\n    \"python.linting.enabled\": true,\n    \"python.linting.pylintEnabled\": true\n}\n

"},{"location":"v1/adv/vscode-ssh/#2-nodejs-development","title":"2. Node.js Development","text":"
# On remote server\n# Install Node.js\ncurl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -\nsudo apt-get install -y nodejs\n\n# Create project\nmkdir myapp && cd myapp\nnpm init -y\nnpm install express\n
"},{"location":"v1/adv/vscode-ssh/#3-docker-development","title":"3. Docker Development","text":"
# On remote server\n# Install Docker (if not already done via Ansible)\nsudo apt install docker.io docker-compose\nsudo usermod -aG docker $USER\n\n# Create Dockerfile\ncat > Dockerfile << EOF\nFROM node:18\nWORKDIR /app\nCOPY package*.json ./\nRUN npm install\nCOPY . .\nEXPOSE 3000\nCMD [\"npm\", \"start\"]\nEOF\n

VSCode Docker Integration:

"},{"location":"v1/adv/vscode-ssh/#part-8-troubleshooting-guide","title":"Part 8: Troubleshooting Guide","text":""},{"location":"v1/adv/vscode-ssh/#common-connection-issues","title":"Common Connection Issues","text":"

Problem: \"Could not establish connection to remote host\"

Solutions:

# Check Tailscale connectivity\ntailscale status\nping <tailscale-ip>\n\n# Test SSH manually\nssh <username>@<tailscale-ip>\n\n# Check SSH config syntax\nssh -T node1\n

Problem: \"Permission denied (publickey)\"

Solutions:

# Check SSH key permissions\nchmod 600 ~/.ssh/id_rsa\nchmod 600 ~/.ssh/config\n\n# Verify SSH agent\nssh-add ~/.ssh/id_rsa\nssh-add -l\n\n# Test SSH connection verbosely\nssh -v <username>@<tailscale-ip>\n

Problem: \"Host key verification failed\"

Solutions:

# Remove old host key\nssh-keygen -R <tailscale-ip>\n\n# Or disable host key checking (less secure)\n# Add to SSH config:\n# StrictHostKeyChecking no\n

"},{"location":"v1/adv/vscode-ssh/#vscode-specific-issues","title":"VSCode-Specific Issues","text":"

Problem: Extensions not working on remote

Solutions:

  1. Install extensions specifically for the remote server
  2. Check extension compatibility with remote development
  3. Reload VSCode window: Ctrl+Shift+P \u2192 \"Developer: Reload Window\"

Problem: Slow performance

Solutions: - Use .vscode/settings.json to exclude large directories:

{\n    \"files.watcherExclude\": {\n        \"**/node_modules/**\": true,\n        \"**/.git/objects/**\": true,\n        \"**/dist/**\": true\n    }\n}\n

Problem: Terminal not starting

Solutions:

# Check shell path in remote settings\n\"terminal.integrated.shell.linux\": \"/bin/bash\"\n\n# Or let VSCode auto-detect\n\"terminal.integrated.defaultProfile.linux\": \"bash\"\n

"},{"location":"v1/adv/vscode-ssh/#network-and-performance-issues","title":"Network and Performance Issues","text":"

Problem: Connection timeouts

Solutions: Add to SSH config:

ServerAliveInterval 60\nServerAliveCountMax 3\nTCPKeepAlive yes\n

Problem: File transfer slow

Solutions: - Use .vscodeignore to exclude unnecessary files - Compress large files before transfer - Use rsync for large file operations:

rsync -avz --progress localdir/ <username>@<tailscale-ip>:remotedir/\n

"},{"location":"v1/adv/vscode-ssh/#part-9-best-practices","title":"Part 9: Best Practices","text":""},{"location":"v1/adv/vscode-ssh/#security-best-practices","title":"Security Best Practices","text":"
  1. Use SSH keys, never passwords
  2. Keep SSH agent secure
  3. Regular security updates on remote servers
  4. Use VSCode's secure connection verification
"},{"location":"v1/adv/vscode-ssh/#performance-optimization","title":"Performance Optimization","text":"
  1. Exclude unnecessary files:

    // .vscode/settings.json\n{\n    \"files.watcherExclude\": {\n        \"**/node_modules/**\": true,\n        \"**/.git/**\": true,\n        \"**/dist/**\": true,\n        \"**/build/**\": true\n    },\n    \"search.exclude\": {\n        \"**/node_modules\": true,\n        \"**/bower_components\": true,\n        \"**/*.code-search\": true\n    }\n}\n

  2. Use remote workspace for large projects

  3. Close unnecessary windows and extensions
  4. Use efficient development workflows
"},{"location":"v1/adv/vscode-ssh/#development-workflow","title":"Development Workflow","text":"
  1. Use version control effectively:

    # Always work in Git repositories\ngit status\ngit add .\ngit commit -m \"feature: add new functionality\"\ngit push origin main\n

  2. Environment separation:

    # Development\nssh node1\ncd /home/<username>/dev-projects\n\n# Production\nssh node2\ncd /opt/production-apps\n

  3. Backup important work:

    # Regular backups via Git\ngit push origin main\n\n# Or manual backup\nscp -r <username>@<tailscale-ip>:/important/project ./backup/\n

"},{"location":"v1/adv/vscode-ssh/#part-10-team-collaboration","title":"Part 10: Team Collaboration","text":""},{"location":"v1/adv/vscode-ssh/#shared-development-servers","title":"Shared Development Servers","text":"

SSH Config for Team:

# Shared development server\nHost team-dev\n    HostName <tailscale-ip>\n    User <team-user>\n    IdentityFile ~/.ssh/team_dev_key\n    ForwardAgent yes\n\n# Personal development\nHost my-dev\n    HostName <tailscale-ip>\n    User <username>\n    IdentityFile ~/.ssh/id_rsa\n

"},{"location":"v1/adv/vscode-ssh/#project-structure","title":"Project Structure","text":"
/opt/projects/\n\u251c\u2500\u2500 project-a/\n\u2502   \u251c\u2500\u2500 dev/          # Development branch\n\u2502   \u251c\u2500\u2500 staging/      # Staging environment\n\u2502   \u2514\u2500\u2500 docs/         # Documentation\n\u251c\u2500\u2500 project-b/\n\u2514\u2500\u2500 shared-tools/     # Common utilities\n
"},{"location":"v1/adv/vscode-ssh/#access-management","title":"Access Management","text":"
# Create shared project directory\nsudo mkdir -p /opt/projects\nsudo chown -R :developers /opt/projects\nsudo chmod -R g+w /opt/projects\n\n# Add users to developers group\nsudo usermod -a -G developers <username>\n
"},{"location":"v1/adv/vscode-ssh/#quick-reference","title":"Quick Reference","text":""},{"location":"v1/adv/vscode-ssh/#essential-vscode-remote-commands","title":"Essential VSCode Remote Commands","text":"
# Command Palette shortcuts\nCtrl+Shift+P \u2192 \"Remote-SSH: Connect to Host...\"\nCtrl+Shift+P \u2192 \"Remote-SSH: Open SSH Configuration File...\"\nCtrl+Shift+P \u2192 \"Remote-SSH: Kill VS Code Server on Host...\"\n\n# Terminal\nCtrl+` \u2192 Open integrated terminal\nCtrl+Shift+` \u2192 Create new terminal\n\n# File operations\nCtrl+O \u2192 Open file\nCtrl+S \u2192 Save file\nCtrl+Shift+E \u2192 Focus file explorer\n
"},{"location":"v1/adv/vscode-ssh/#ssh-connection-quick-test","title":"SSH Connection Quick Test","text":"
# Test connectivity\nssh -T node1\n\n# Connect with verbose output\nssh -v <username>@<tailscale-ip>\n\n# Check SSH config\nssh -F ~/.ssh/config node1\n
"},{"location":"v1/adv/vscode-ssh/#port-forwarding-commands","title":"Port Forwarding Commands","text":"
# Manual port forwarding\nssh -L 3000:localhost:3000 <username>@<tailscale-ip>\n\n# Background tunnel\nssh -f -N -L 8080:localhost:80 <username>@<tailscale-ip>\n
"},{"location":"v1/adv/vscode-ssh/#conclusion","title":"Conclusion","text":"

This remote development setup provides:

The combination of VSCode Remote Development with Tailscale networking creates a powerful, flexible development environment that works from anywhere while maintaining security and performance.

Whether developing Python applications, Node.js services, or managing Docker containers, this setup provides a professional remote development experience that rivals local development while leveraging the power and resources of remote servers.

"},{"location":"v1/build/","title":"Getting Started","text":"

Welcome to Changemaker-Lite! You're about to reclaim your digital sovereignty and stop feeding your secrets to corporations. This guide will help you set up your own political infrastructure that you actually own and control.

This documentation is broken into a few sections, which you can see in the navigation bar to the left:

Of course, everything is also searachable, so if you want to find something specific, just use the search bar at the top right.

If you come across anything that is unclear, please open an issue in the Git Repository, reach out to us at admin@thebunkerops.ca, or edit it yourself by clicking the pencil icon at the top right of each page.

"},{"location":"v1/build/#quick-start","title":"Quick Start","text":""},{"location":"v1/build/#build-changemaker-lite","title":"Build Changemaker-Lite","text":"
# Clone the repository\ngit clone https://gitea.bnkops.com/admin/changemaker.lite\ncd changemaker.lite\n

Cloudflare Credentials

The config.sh script will ask you for your optional Cloudflare credentials to get started. You can find more information on how to find this in the Cloudlflare Configuration

# Configure environment (creates .env file)\n./config.sh\n
# Start all services\ndocker compose up -d\n
"},{"location":"v1/build/#optional-site-builld","title":"Optional - Site Builld","text":"

If you want to have your site prepared for launch, you can now proceed with reseting the site build. See Build Site for more detials.

"},{"location":"v1/build/#deploy","title":"Deploy","text":"

Cloudflare

Right now, we suggest deploying using Cloudflare for simplicity and protections against 99% of surface level attacks to digital infrastructure. If you want to avoid using this service, we recommend checking out Pagolin as a drop in replacement.

For secure public access, use the production deployment script:

./start-production.sh\n
"},{"location":"v1/build/#map","title":"Map","text":"

Map is the canvassing application that is custom view of nocodb data. Map is best built after production deployment to reduce duplicate build efforts.

Instructions on how to build the map are available in the map manual in the build directory.

"},{"location":"v1/build/#quick-start-for-map","title":"Quick Start for Map","text":"

Get your NocoDB API token and URL, update the .env file in the map directory, and then run:

cd map\nchmod +x build-nocodb.sh # builds the nocodb tables\n./build-nocodb.sh\n
Copy the urls of the newly created nocodb views and update the .env file in the map directory with them, and then run:

cd map\ndocker compose up -d\n

You Map instance will be available at http://localhost:3000 or on the domain you set up during production deployment.

"},{"location":"v1/build/#why-changemaker-lite","title":"Why Changemaker Lite?","text":"

Before we dive into the technical setup, let's be clear about what you're doing here:

The Reality

If you do politics, who is reading your secrets? Every corporate platform you use is extracting your power, selling your data, and building profiles on your community. It's time to break free.

"},{"location":"v1/build/#what-youre-getting","title":"What You're Getting","text":""},{"location":"v1/build/#what-youre-leaving-behind","title":"What You're Leaving Behind","text":""},{"location":"v1/build/#system-requirements","title":"System Requirements","text":""},{"location":"v1/build/#operating-system","title":"Operating System","text":"

Getting Started on Ubuntu

Want some help getting started with a baseline buildout for a Ubuntu server? You can use our BNKops Server Build Script

New to Linux?

Consider Linux Mint - it looks like Windows but opens the door to true digital freedom.

"},{"location":"v1/build/#hardware-requirements","title":"Hardware Requirements","text":"

Cloud Hosting

You can run this on a VPS from providers like Hetzner, DigitalOcean, or Linode for ~$20/month.

"},{"location":"v1/build/#software-prerequisites","title":"Software Prerequisites","text":"

Ensure the following software is installed on your system. The BNKops Server Build Script can help set these up if you're on Ubuntu.

  1. Docker Engine (24.0+)
# Install Docker\ncurl -fsSL https://get.docker.com | sudo sh\n\n# Add your user to docker group\nsudo usermod -aG docker $USER\n\n# Log out and back in for group changes to take effect\n
  1. Docker Compose (v2.20+)
# Verify Docker Compose v2 is installed\ndocker compose version\n
  1. Essential Tools
# Install required packages\nsudo apt update\nsudo apt install -y git curl jq openssl\n
"},{"location":"v1/build/#installation","title":"Installation","text":""},{"location":"v1/build/#1-clone-repository","title":"1. Clone Repository","text":"
git clone https://gitea.bnkops.com/admin/changemaker.lite\ncd changemaker.lite\n
"},{"location":"v1/build/#2-run-configuration-wizard","title":"2. Run Configuration Wizard","text":"

The config.sh script will guide you through the initial setup:

./config.sh\n

This wizard will:

"},{"location":"v1/build/#configuration-options","title":"Configuration Options","text":"

During setup, you'll be prompted for:

  1. Domain Name: Your primary domain (e.g., example.com)
  2. Cloudflare Settings (optional):
  3. API Token
  4. Zone ID
  5. Account ID
  6. Admin Credentials:
  7. Listmonk admin email and password
  8. n8n admin email and password
"},{"location":"v1/build/#3-start-services","title":"3. Start Services","text":"

Launch all services with Docker Compose:

docker compose up -d\n

Wait for services to initialize (first run may take 5-10 minutes):

# Watch container status\ndocker compose ps\n\n# View logs\ndocker compose logs -f\n
"},{"location":"v1/build/#4-verify-installation","title":"4. Verify Installation","text":"

Check that all services are running:

docker compose ps\n

Expected output should show all services as \"Up\":

"},{"location":"v1/build/#local-access","title":"Local Access","text":"

Once services are running, access them locally:

"},{"location":"v1/build/#homepage-dashboard","title":"\ud83c\udfe0 Homepage Dashboard","text":""},{"location":"v1/build/#development-tools","title":"\ud83d\udcbb Development Tools","text":""},{"location":"v1/build/#communication","title":"\ud83d\udce7 Communication","text":""},{"location":"v1/build/#automation-data","title":"\ud83d\udd04 Automation & Data","text":""},{"location":"v1/build/#interactive-tools","title":"\ud83d\udee0\ufe0f Interactive Tools","text":""},{"location":"v1/build/#map_1","title":"Map","text":"

Map

Map is the canvassing application that is custom view of nocodb data. Map is best built after production deployment to reduce duplicate build efforts.

"},{"location":"v1/build/#map-manual","title":"Map Manual","text":""},{"location":"v1/build/#production-deployment","title":"Production Deployment","text":""},{"location":"v1/build/#deploy-with-cloudflare-tunnels","title":"Deploy with Cloudflare Tunnels","text":"

For secure public access, use the production deployment script:

./start-production.sh\n

This script will:

  1. Install and configure cloudflared
  2. Create a Cloudflare tunnel
  3. Set up DNS records automatically
  4. Configure access policies
  5. Create a systemd service for persistence
"},{"location":"v1/build/#what-happens-during-production-setup","title":"What Happens During Production Setup","text":"
  1. Cloudflare Authentication: Browser-based login to Cloudflare
  2. Tunnel Creation: Secure tunnel named changemaker-lite
  3. DNS Configuration: Automatic CNAME records for all services
  4. Access Policies: Email-based authentication for sensitive services
  5. Service Installation: Systemd service for automatic startup
"},{"location":"v1/build/#production-urls","title":"Production URLs","text":"

After successful deployment, services will be available at:

Public Services:

Protected Services (require authentication):

"},{"location":"v1/build/#configuration-management","title":"Configuration Management","text":""},{"location":"v1/build/#environment-variables","title":"Environment Variables","text":"

Key settings in .env file:

# Domain Configuration\nDOMAIN=yourdomain.com\nBASE_DOMAIN=https://yourdomain.com\n\n# Service Ports (automatically assigned to avoid conflicts)\nHOMEPAGE_PORT=3010\nCODE_SERVER_PORT=8888\nLISTMONK_PORT=9000\nMKDOCS_PORT=4000\nMKDOCS_SITE_SERVER_PORT=4001\nN8N_PORT=5678\nNOCODB_PORT=8090\nGITEA_WEB_PORT=3030\nGITEA_SSH_PORT=2222\nMAP_PORT=3000\nMINI_QR_PORT=8089\n\n# Cloudflare (for production)\nCF_API_TOKEN=your_token\nCF_ZONE_ID=your_zone_id\nCF_ACCOUNT_ID=your_account_id\n
"},{"location":"v1/build/#reconfigure-services","title":"Reconfigure Services","text":"

To update configuration:

# Re-run configuration wizard\n./config.sh\n\n# Restart services\ndocker compose down && docker compose up -d\n
"},{"location":"v1/build/#common-tasks","title":"Common Tasks","text":""},{"location":"v1/build/#service-management","title":"Service Management","text":"
# View all services\ndocker compose ps\n\n# View logs for specific service\ndocker compose logs -f [service-name]\n\n# Restart a service\ndocker compose restart [service-name]\n\n# Stop all services\ndocker compose down\n\n# Stop and remove all data (CAUTION!)\ndocker compose down -v\n
"},{"location":"v1/build/#backup-data","title":"Backup Data","text":"
# Backup all volumes\ndocker run --rm -v changemaker_listmonk-data:/data -v $(pwd):/backup alpine tar czf /backup/listmonk-backup.tar.gz -C /data .\n\n# Backup configuration\ntar czf configs-backup.tar.gz configs/\n\n# Backup documentation\ntar czf docs-backup.tar.gz mkdocs/docs/\n
"},{"location":"v1/build/#update-services","title":"Update Services","text":"
# Pull latest images\ndocker compose pull\n\n# Recreate containers with new images\ndocker compose up -d\n
"},{"location":"v1/build/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/build/#port-conflicts","title":"Port Conflicts","text":"

If services fail to start due to port conflicts:

  1. Check which ports are in use:
sudo ss -tulpn | grep LISTEN\n
  1. Re-run configuration to get new ports:
./config.sh\n
  1. Or manually edit .env file and change conflicting ports
"},{"location":"v1/build/#permission-issues","title":"Permission Issues","text":"

Fix permission problems:

# Get your user and group IDs\nid -u  # User ID\nid -g  # Group ID\n\n# Update .env file with correct IDs\nUSER_ID=1000\nGROUP_ID=1000\n\n# Restart services\ndocker compose down && docker compose up -d\n
"},{"location":"v1/build/#service-wont-start","title":"Service Won't Start","text":"

Debug service issues:

# Check detailed logs\ndocker compose logs [service-name] --tail 50\n\n# Check container status\ndocker ps -a\n\n# Inspect container\ndocker inspect [container-name]\n
"},{"location":"v1/build/#cloudflare-tunnel-issues","title":"Cloudflare Tunnel Issues","text":"
# Check tunnel service status\nsudo systemctl status cloudflared-changemaker\n\n# View tunnel logs\nsudo journalctl -u cloudflared-changemaker -f\n\n# Restart tunnel\nsudo systemctl restart cloudflared-changemaker\n
"},{"location":"v1/build/#next-steps","title":"Next Steps","text":"

Now that your Changemaker Lite instance is running:

  1. Set up Listmonk - Configure SMTP and create your first campaign
  2. Create workflows - Build automations in n8n
  3. Import data - Set up your NocoDB databases
  4. Configure map - Add location data for the map viewer
  5. Write documentation - Start creating content in MkDocs
  6. Set up Git - Initialize repositories in Gitea
"},{"location":"v1/build/#getting-help","title":"Getting Help","text":""},{"location":"v1/build/influence/","title":"Influence Build Guide","text":"

Influence is BNKops campaign tool for connecting Alberta residents with their elected representatives across all levels of government.

Complete Configuration

For detailed configuration, usage instructions, and troubleshooting, see the main Influence README.

Email Testing

The application includes MailHog integration for safe email testing during development. All test emails are caught locally and never sent to actual representatives.

"},{"location":"v1/build/influence/#prerequisites","title":"Prerequisites","text":""},{"location":"v1/build/influence/#quick-build-process","title":"Quick Build Process","text":""},{"location":"v1/build/influence/#1-get-nocodb-api-token","title":"1. Get NocoDB API Token","text":"
  1. Login to your NocoDB instance
  2. Click user icon \u2192 Account Settings \u2192 API Tokens
  3. Create new token with read/write permissions
  4. Copy the token for the next step
"},{"location":"v1/build/influence/#2-configure-environment","title":"2. Configure Environment","text":"

Navigate to the influence directory and create your environment file:

cd influence\ncp example.env .env\n

Edit the .env file with your configuration:

"},{"location":"v1/build/influence/#development-mode-configuration","title":"Development Mode Configuration","text":"

For development and testing, use MailHog to catch emails:

# Development Mode\nNODE_ENV=development\nEMAIL_TEST_MODE=true\n\n# MailHog SMTP (for development)\nSMTP_HOST=mailhog\nSMTP_PORT=1025\nSMTP_SECURE=false\nSMTP_USER=test\nSMTP_PASS=test\nSMTP_FROM_EMAIL=dev@albertainfluence.local\nSMTP_FROM_NAME=\"BNKops Influence Campaign (DEV)\"\n\n# Email Testing\nTEST_EMAIL_RECIPIENT=developer@example.com\n
"},{"location":"v1/build/influence/#3-auto-create-database-structure","title":"3. Auto-Create Database Structure","text":"

Run the build script to create required NocoDB tables:

chmod +x scripts/build-nocodb.sh\n./scripts/build-nocodb.sh\n

This creates six tables: - Campaigns - Campaign configurations with email templates and settings - Campaign Emails - Tracking of all emails sent through campaigns - Representatives - Cached representative data by postal code - Email Logs - System-wide email delivery logs - Postal Codes - Canadian postal code geolocation data - Users - Admin authentication and access control

"},{"location":"v1/build/influence/#4-build-and-deploy","title":"4. Build and Deploy","text":"

Build the Docker image and start the application:

# Build the Docker image\ndocker compose build\n\n# Start the application (includes MailHog in development)\ndocker compose up -d\n
"},{"location":"v1/build/influence/#verify-installation","title":"Verify Installation","text":"
  1. Check container status:

    docker compose ps\n

  2. View logs:

    docker compose logs -f app\n

  3. Access the application:

  4. Main App: http://localhost:3333
  5. Admin Panel: http://localhost:3333/admin.html
  6. Email Testing (dev): http://localhost:3333/email-test.html
  7. MailHog UI (dev): http://localhost:8025
"},{"location":"v1/build/influence/#initial-setup","title":"Initial Setup","text":""},{"location":"v1/build/influence/#1-create-admin-user","title":"1. Create Admin User","text":"

Access the admin panel at /admin.html and create your first administrator account.

"},{"location":"v1/build/influence/#2-create-your-first-campaign","title":"2. Create Your First Campaign","text":"
  1. Login to the admin panel
  2. Click \"Create Campaign\"
  3. Configure basic settings:
  4. Campaign title and description
  5. Email subject and body template
  6. Upload cover photo (optional)
  7. Set campaign options:
  8. \u2705 Allow SMTP Email - Enable server-side sending
  9. \u2705 Allow Mailto Link - Enable browser-based mailto
  10. \u2705 Collect User Info - Request name and email
  11. \u2705 Show Email Count - Display engagement metrics
  12. \u2705 Allow Email Editing - Let users customize message
  13. Select target government levels (Federal, Provincial, Municipal, School Board)
  14. Set status to Active to make campaign public
  15. Click \"Create Campaign\"
"},{"location":"v1/build/influence/#3-test-representative-lookup","title":"3. Test Representative Lookup","text":"
  1. Visit the homepage
  2. Enter an Alberta postal code (e.g., T5N4B8)
  3. View representatives at all government levels
  4. Test email sending functionality
"},{"location":"v1/build/influence/#development-workflow","title":"Development Workflow","text":""},{"location":"v1/build/influence/#email-testing-interface","title":"Email Testing Interface","text":"

Access the email testing interface at /email-test.html (requires admin login):

Features: - \ud83d\udce7 Quick Test - Send test email with one click - \ud83d\udc41\ufe0f Email Preview - Preview email formatting before sending - \u270f\ufe0f Custom Composition - Test with custom subject and message - \ud83d\udcca Email Logs - View all sent emails with filtering - \ud83d\udd27 SMTP Diagnostics - Test connection and troubleshoot

"},{"location":"v1/build/influence/#mailhog-web-interface","title":"MailHog Web Interface","text":"

Access MailHog at http://localhost:8025 to: - View all caught emails during development - Inspect email content, headers, and formatting - Search and filter test emails - Verify emails never leave your local environment

"},{"location":"v1/build/influence/#switching-to-production","title":"Switching to Production","text":"

When ready to deploy to production:

  1. Update .env with production SMTP settings:

    EMAIL_TEST_MODE=false\nNODE_ENV=production\nSMTP_HOST=smtp.your-provider.com\nSMTP_USER=your-real-email@domain.com\nSMTP_PASS=your-real-password\n

  2. Restart the application:

    docker compose restart\n

"},{"location":"v1/build/influence/#key-features","title":"Key Features","text":""},{"location":"v1/build/influence/#representative-lookup","title":"Representative Lookup","text":""},{"location":"v1/build/influence/#campaign-system","title":"Campaign System","text":""},{"location":"v1/build/influence/#email-integration","title":"Email Integration","text":""},{"location":"v1/build/influence/#api-endpoints","title":"API Endpoints","text":""},{"location":"v1/build/influence/#public-endpoints","title":"Public Endpoints","text":""},{"location":"v1/build/influence/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":""},{"location":"v1/build/influence/#maintenance-commands","title":"Maintenance Commands","text":""},{"location":"v1/build/influence/#update-application","title":"Update Application","text":"
docker compose down\ngit pull origin main\ndocker compose build\ndocker compose up -d\n
"},{"location":"v1/build/influence/#development-mode","title":"Development Mode","text":"
cd app\nnpm install\nnpm run dev\n
"},{"location":"v1/build/influence/#view-logs","title":"View Logs","text":"
# Follow application logs\ndocker compose logs -f app\n\n# View MailHog logs (development)\ndocker compose logs -f mailhog\n
"},{"location":"v1/build/influence/#database-backup","title":"Database Backup","text":"
# Backup is handled through NocoDB\n# Access NocoDB admin panel to export tables\n
"},{"location":"v1/build/influence/#health-check","title":"Health Check","text":"
curl http://localhost:3333/api/health\n
"},{"location":"v1/build/influence/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/build/influence/#nocodb-connection-issues","title":"NocoDB Connection Issues","text":""},{"location":"v1/build/influence/#email-not-sending","title":"Email Not Sending","text":""},{"location":"v1/build/influence/#no-representatives-found","title":"No Representatives Found","text":""},{"location":"v1/build/influence/#campaign-not-appearing","title":"Campaign Not Appearing","text":""},{"location":"v1/build/influence/#production-deployment","title":"Production Deployment","text":""},{"location":"v1/build/influence/#environment-configuration","title":"Environment Configuration","text":"
NODE_ENV=production\nEMAIL_TEST_MODE=false\nPORT=3333\n\n# Use production SMTP settings\nSMTP_HOST=smtp.your-provider.com\nSMTP_PORT=587\nSMTP_SECURE=false\nSMTP_USER=your-production-email@domain.com\nSMTP_PASS=your-production-password\n
"},{"location":"v1/build/influence/#docker-production","title":"Docker Production","text":"
# Build and start in production mode\ndocker compose -f docker-compose.yml up -d --build\n\n# View logs\ndocker compose logs -f app\n\n# Monitor health\nwatch curl http://localhost:3333/api/health\n
"},{"location":"v1/build/influence/#monitoring","title":"Monitoring","text":""},{"location":"v1/build/influence/#security-considerations","title":"Security Considerations","text":""},{"location":"v1/build/influence/#support","title":"Support","text":"

For detailed configuration, troubleshooting, and usage instructions, see: - Main Influence README - Campaign Settings Guide - Files Explainer

"},{"location":"v1/build/map/","title":"Map Build Guide","text":"

Map is BNKops canvassing application built for community organizing and door-to-door canvassing.

Complete Configuration

For detailed configuration, usage instructions, and troubleshooting, see the Map Configuration Guide.

Clean NocoDB

Currently the way to get a good result is to ensure the target nocodb database is empty. You can do this by deleting all bases. The script should still work with other volumes however may insert tables into odd locations; still debugging. Again, see config if needing to do manually.

"},{"location":"v1/build/map/#prerequisites","title":"Prerequisites","text":""},{"location":"v1/build/map/#quick-build-process","title":"Quick Build Process","text":""},{"location":"v1/build/map/#1-get-nocodb-api-token","title":"1. Get NocoDB API Token","text":"
  1. Login to your NocoDB instance
  2. Click user icon \u2192 Account Settings \u2192 API Tokens
  3. Create new token with read/write permissions
  4. Copy the token for the next step
"},{"location":"v1/build/map/#2-configure-environment","title":"2. Configure Environment","text":"

Edit the .env file in the map/ directory:

cd map\n

Update your .env file with your NocoDB details, specifically the instance and api token:

NOCODB_API_URL=[change me]\nNOCODB_API_TOKEN=[change me]\n\n# NocoDB View URL is the URL to your NocoDB view where the map data is stored.\nNOCODB_VIEW_URL=[change me]\n\n# NOCODB_LOGIN_SHEET is the URL to your NocoDB login sheet.\nNOCODB_LOGIN_SHEET=[change me]\n\n# NOCODB_SETTINGS_SHEET is the URL to your NocoDB settings sheet.\nNOCODB_SETTINGS_SHEET=[change me]\n\n# NOCODB_SHIFTS_SHEET is the URL to your shifts sheet.\nNOCODB_SHIFTS_SHEET=[change me]\n\n# NOCODB_SHIFT_SIGNUPS_SHEET is the URL to your NocoDB shift signups sheet where users can add their own shifts.\nNOCODB_SHIFT_SIGNUPS_SHEET=[change me]\n\n# NOCODB_CUTS_SHEET is the URL to your NocoDB Cuts sheet.\nNOCODB_CUTS_SHEET=[change me]\n\nDOMAIN=[change me]\n\n# MkDocs Integration\nMKDOCS_URL=[change me]\nMKDOCS_SEARCH_URL=[change me]\nMKDOCS_SITE_SERVER_PORT=4002\n\n# Server Configuration\nPORT=3000\nNODE_ENV=production\n\n# Session Secret (IMPORTANT: Generate a secure random string for production)\nSESSION_SECRET=[change me]\n\n# Map Defaults (Edmonton, Alberta, Canada)\nDEFAULT_LAT=53.5461\nDEFAULT_LNG=-113.4938\nDEFAULT_ZOOM=11\n\n# Optional: Map Boundaries (prevents users from adding points outside area)\n# BOUND_NORTH=53.7\n# BOUND_SOUTH=53.4\n# BOUND_EAST=-113.3\n# BOUND_WEST=-113.7\n\n# Cloudflare Settings\nTRUST_PROXY=true\nCOOKIE_DOMAIN=[change me]\n\n# Update NODE_ENV to production for HTTPS\nNODE_ENV=production\n\n# Add allowed origin\nALLOWED_ORIGINS=[change me]\n\n# SMTP Configuration\nSMTP_HOST=[change me]\nSMTP_PORT=587   \nSMTP_SECURE=false\nSMTP_USER=[change me]\nSMTP_PASS=[change me]\nEMAIL_FROM_NAME=\"[change me]\"\nEMAIL_FROM_ADDRESS=[change me]\n\n# App Configuration\nAPP_NAME=\"[change me]\"\n\n# Listmonk Configuration\nLISTMONK_API_URL=[change me]\nLISTMONK_USERNAME=[change me]\nLISTMONK_PASSWORD=[change me]\nLISTMONK_SYNC_ENABLED=true\nLISTMONK_INITIAL_SYNC=false  # Set to true only for first run to sync existing data\n
"},{"location":"v1/build/map/#3-auto-create-database-structure","title":"3. Auto-Create Database Structure","text":"

Run the build script to create required tables:

chmod +x build-nocodb.sh\n./build-nocodb.sh\n

This creates three tables: - Locations - Main map data with geo-location, contact info, support levels - Login - User authentication (email, name, admin flag) - Settings - Admin configuration and QR codes

"},{"location":"v1/build/map/#4-get-table-urls","title":"4. Get Table URLs","text":"

After the script completes:

  1. Login to your NocoDB instance
  2. Navigate to your project (\"Map Viewer Project\")
  3. Copy the view URLs for each table from your browser address bar
  4. URLs should look like: https://your-nocodb.com/dashboard/#/nc/project-id/table-id
"},{"location":"v1/build/map/#5-update-environment-with-urls","title":"5. Update Environment with URLs","text":"

Edit your .env file and add the table URLs:

# NocoDB View URL is the URL to your NocoDB view where the map data is stored.\nNOCODB_VIEW_URL=[change me]\n\n# NOCODB_LOGIN_SHEET is the URL to your NocoDB login sheet.\nNOCODB_LOGIN_SHEET=[change me]\n\n# NOCODB_SETTINGS_SHEET is the URL to your NocoDB settings sheet.\nNOCODB_SETTINGS_SHEET=[change me]\n\n# NOCODB_SHIFTS_SHEET is the URL to your shifts sheet.\nNOCODB_SHIFTS_SHEET=[change me]\n\n# NOCODB_SHIFT_SIGNUPS_SHEET is the URL to your NocoDB shift signups sheet where users can add their own shifts.\nNOCODB_SHIFT_SIGNUPS_SHEET=[change me]\n\n# NOCODB_CUTS_SHEET is the URL to your NocoDB Cuts sheet.\nNOCODB_CUTS_SHEET=[change me]\n
"},{"location":"v1/build/map/#6-build-and-deploy","title":"6. Build and Deploy","text":"

Build the Docker image and start the application:

# Build the Docker image\ndocker-compose build\n\n# Start the application\ndocker-compose up -d\n
"},{"location":"v1/build/map/#verify-installation","title":"Verify Installation","text":"
  1. Check container status:

    docker-compose ps\n

  2. View logs:

    docker-compose logs -f map-viewer\n

  3. Access the application at http://localhost:3000

"},{"location":"v1/build/map/#quick-start","title":"Quick Start","text":"
  1. Login: Use an email from your Login table
  2. Add Locations: Click on the map to add new locations
  3. Admin Panel: Admin users can access /admin.html for configuration
  4. Walk Sheets: Generate printable canvassing forms with QR codes
"},{"location":"v1/build/map/#maintenance-commands","title":"Maintenance Commands","text":""},{"location":"v1/build/map/#update-application","title":"Update Application","text":"
docker-compose down\ngit pull origin main\ndocker-compose build\ndocker-compose up -d\n
"},{"location":"v1/build/map/#development-mode","title":"Development Mode","text":"
cd app\nnpm install\nnpm run dev\n
"},{"location":"v1/build/map/#health-check","title":"Health Check","text":"
curl http://localhost:3000/health\n
"},{"location":"v1/build/map/#support","title":"Support","text":"

For detailed configuration, troubleshooting, and usage instructions, see the Map Configuration Guide.

"},{"location":"v1/build/server/","title":"BNKops Server Build","text":"

Purpose: a Ubuntu server build-out for general application

This documentation is a overview of the full build out for a server OS and baseline for running Changemaker-lite. It is a manual to re-install this server on any machine.

All of the following systems are free and the majority are open source.

"},{"location":"v1/build/server/#ubuntu-os","title":"Ubuntu OS","text":"

Ubuntu is a Linux distribution derived from Debian and composed mostly of free and open-source software.

"},{"location":"v1/build/server/#install-ubuntu","title":"Install Ubuntu","text":""},{"location":"v1/build/server/#post-install","title":"Post Install","text":"

Post installation, run update:

sudo apt update\n

sudo apt upgrade\n
"},{"location":"v1/build/server/#configuration","title":"Configuration","text":"

Further configurations:

"},{"location":"v1/build/server/#vscode-insiders","title":"VSCode Insiders","text":"

Visual Studio Code is a new choice of tool that combines the simplicity of a code editor with what developers need for the core edit-build-debug cycle.

"},{"location":"v1/build/server/#install-using-app-centre","title":"Install Using App Centre","text":""},{"location":"v1/build/server/#obsidian","title":"Obsidian","text":"

The free and flexible app for your private\u00a0thoughts.

"},{"location":"v1/build/server/#install-using-app-center","title":"Install Using App Center","text":""},{"location":"v1/build/server/#curl","title":"Curl","text":"

command line tool and library for transferring data with URLs (since 1998)

"},{"location":"v1/build/server/#install","title":"Install","text":"
sudo apt install curl \n
"},{"location":"v1/build/server/#glances","title":"Glances","text":"

Glances an Eye on your system. A top/htop alternative for GNU/Linux, BSD, Mac OS and Windows operating systems.

"},{"location":"v1/build/server/#install_1","title":"Install","text":"
sudo snap install glances \n
"},{"location":"v1/build/server/#syncthing","title":"Syncthing","text":"

Syncthing is a continuous file synchronization program. It synchronizes files between two or more computers in real time, safely protected from prying eyes. Your data is your data alone and you deserve to choose where it is stored, whether it is shared with some third party, and how it\u2019s transmitted over the internet.

"},{"location":"v1/build/server/#install_2","title":"Install","text":"
# Add the release PGP keys:\nsudo mkdir -p /etc/apt/keyrings\nsudo curl -L -o /etc/apt/keyrings/syncthing-archive-keyring.gpg https://syncthing.net/release-key.gpg\n
# Add the \"stable\" channel to your APT sources:\necho \"deb [signed-by=/etc/apt/keyrings/syncthing-archive-keyring.gpg] https://apt.syncthing.net/ syncthing stable\" | sudo tee /etc/apt/sources.list.d/syncthing.list\n
# Update and install syncthing:\nsudo apt-get update\nsudo apt-get install syncthing\n
"},{"location":"v1/build/server/#post-install_1","title":"Post Install","text":"

Run syncthing as a system service.

sudo systemctl start syncthing@yourusername\n

sudo systemctl enable syncthing@yourusername\n
"},{"location":"v1/build/server/#docker","title":"Docker","text":"

Docker helps developers build, share, run, and verify applications anywhere \u2014 without tedious environment configuration or management.

# Add Docker's official GPG key:\nsudo apt-get update\nsudo apt-get install ca-certificates curl\nsudo install -m 0755 -d /etc/apt/keyrings\nsudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc\nsudo chmod a+r /etc/apt/keyrings/docker.asc\n\n# Add the repository to Apt sources:\necho \\\n  \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \\\n  $(. /etc/os-release && echo \"${UBUNTU_CODENAME:-$VERSION_CODENAME}\") stable\" | \\\n  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null\nsudo apt-get update\n

sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin\n
"},{"location":"v1/build/server/#update-users","title":"Update Users","text":"
sudo groupadd docker\n
sudo usermod -aG docker $USER\n
newgrp docker\n
"},{"location":"v1/build/server/#enable-on-boot","title":"Enable on Boot","text":"
sudo systemctl enable docker.service\nsudo systemctl enable containerd.service\n
"},{"location":"v1/build/server/#cloudflared","title":"Cloudflared","text":"

Connect, protect, and build everywhere. We make websites, apps, and networks faster and more secure. Our developer platform is the best place to build modern apps and deliver AI initiatives.

sudo mkdir -p --mode=0755 /usr/share/keyrings\ncurl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null\n
echo \"deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main\" | sudo tee /etc/apt/sources.list.d/cloudflared.list\n
sudo apt-get update && sudo apt-get install cloudflared\n
"},{"location":"v1/build/server/#post-install_2","title":"Post Install","text":"

Login to Cloudflare

cloudflared login\n

"},{"location":"v1/build/server/#configuration_1","title":"Configuration","text":"

The ./config.sh and ./start-production.sh scripts will properly configure a Cloudflare tunnel and service to put your system online. More info in the Cloudflare Configuration.

"},{"location":"v1/build/server/#pandoc","title":"Pandoc","text":"

If you need to convert files from one markup format into another, pandoc is your swiss-army knife.

sudo apt install pandoc\n
"},{"location":"v1/build/site/","title":"Building the Site with MkDocs Material","text":"

Welcome! This guide will help you get started building and customizing your site using MkDocs Material.

"},{"location":"v1/build/site/#reset-site","title":"Reset Site","text":"

You can read through all the BNKops cmlite documentation already in your docs folder or you can reset your docs folder to a baseline to start and read more manuals here. To reset docs folder to baseline, run the following:

./reset-site.sh\n
"},{"location":"v1/build/site/#how-to-build-your-site-step-by-step","title":"\ud83d\ude80 How to Build Your Site (Step by Step)","text":"
  1. Open your Coder instance. For example: coder.yourdomain.com
  2. Go to the mkdocs folder: In the terminal (for a new terminal press Crtl - Shift - ~), type:
    cd mkdocs\n
  3. Build the site: Type:
    mkdocs build\n
    This creates the static website from your documents and places them in the mkdocs/site directory.

Preview your site locally: Visit localhost:4000 for local development or live.youdomain.com to see a public live load.

Material for MkDocs Documentation

Build vs Serve

Your website is built in stages. Any edits to documents in the mkdocs directory are instantly served and visible at localhost:4000 or if in production mode live.yourdomain.com. The live site is not meant as a public access point and will crash if too many requests are made to it.

Running mkdocs build pushes any changes to the site directory, which then a ngnix server pushes them to the production server for public access at your root domain (yourdomain.com).

You can think of it as serve/live = draft for personal review and build = save/push to production for the public.

This combination allows for rapid development of documentation while ensuring your live site does not get updated until your content is ready.

"},{"location":"v1/build/site/#resetting-the-site","title":"\ud83e\uddf9 Resetting the Site","text":"

If you want to start fresh:

  1. Delete all folders EXCEPT these folders:

  2. Reset the landing page:

  3. Reset the mkdocs.yml

"},{"location":"v1/build/site/#using-ai-to-help-build-your-site","title":"\ud83e\udd16 Using AI to Help Build Your Site","text":""},{"location":"v1/build/site/#first-time-setup-tips","title":"\ud83d\udee0\ufe0f First-Time Setup Tips","text":"

Quick Start Guide

"},{"location":"v1/build/site/#more-resources","title":"\ud83d\udcda More Resources","text":"

Happy building!

"},{"location":"v1/config/","title":"Configuration","text":"

There are several configuration steps to building a production ready Changemaker-Lite.

In the order we suggest doing them:

"},{"location":"v1/config/cloudflare-config/","title":"Configure Cloudflare","text":"

Cloudflare is the largest DNS routing service on the planet. We use their free service tier to provide Changemaker users with a fast, secure, and reliable way to get online that blocks 99% of surface level attacks and has built in user authenticaion (if you so choose to use it)

"},{"location":"v1/config/cloudflare-config/#credentials","title":"Credentials","text":"

The config.sh and start-production.sh scripts require the following Cloudflare credentials to function properly:

"},{"location":"v1/config/cloudflare-config/#1-cloudflare-api-token","title":"1. Cloudflare API Token","text":""},{"location":"v1/config/cloudflare-config/#2-cloudflare-zone-id","title":"2. Cloudflare Zone ID","text":""},{"location":"v1/config/cloudflare-config/#3-cloudflare-account-id","title":"3. Cloudflare Account ID","text":""},{"location":"v1/config/cloudflare-config/#4-cloudflare-tunnel-id-optional-in-configsh-required-in-start-productionsh","title":"4. Cloudflare Tunnel ID (Optional in config.sh, Required in start-production.sh)","text":"

Automatic Configuration of Tunnel

The start-production.sh script will automatically create a tunnel and system service for Cloudflare.

"},{"location":"v1/config/cloudflare-config/#summary-of-required-credentials","title":"Summary of Required Credentials:","text":"
# In .env file\nCF_API_TOKEN=your_cloudflare_api_token\nCF_ZONE_ID=your_cloudflare_zone_id\nCF_ACCOUNT_ID=your_cloudflare_account_id\nCF_TUNNEL_ID=will_be_set_by_start_production  # This will be set by start-production.sh\n
"},{"location":"v1/config/cloudflare-config/#notes","title":"Notes:","text":""},{"location":"v1/config/coder/","title":"Coder Server Configuration","text":"

This section describes the configuration and features of the code-server environment.

"},{"location":"v1/config/coder/#accessing-code-server","title":"Accessing Code Server","text":""},{"location":"v1/config/coder/#retrieving-the-code-server-password","title":"Retrieving the Code Server Password","text":"

After the first build, the code-server password is stored in:

configs/code-server/.config/code-server/config.yaml\n

Look for the password: field in that file. For example:

password: 0c0dca951a2d12eff1665817\n

Note: It is recommended not to change this password manually, as it is securely generated.

"},{"location":"v1/config/coder/#main-configuration-options","title":"Main Configuration Options","text":""},{"location":"v1/config/coder/#installed-tools-and-features","title":"Installed Tools and Features","text":"

The code-server environment includes:

"},{"location":"v1/config/coder/#using-mkdocs","title":"Using MkDocs","text":"

The virtual environment for MkDocs is automatically added to your PATH. You can run MkDocs commands directly, or use the provided script. For example, to build the site, from a clean terminal we would rung:

cd mkdocs \nmkdocs build\n
"},{"location":"v1/config/coder/#claude-code-integration","title":"Claude Code Integration","text":"

The code-server environment comes with Claude Code (@anthropic-ai/claude-code) globally installed via npm.

"},{"location":"v1/config/coder/#what-is-claude-code","title":"What is Claude Code?","text":"

Claude Code is an AI-powered coding assistant by Anthropic, designed to help you write, refactor, and understand code directly within your development environment.

"},{"location":"v1/config/coder/#usage","title":"Usage","text":"

Note: Claude Code requires an API key or account with Anthropic for full functionality. Refer to the extension settings for configuration.

"},{"location":"v1/config/coder/#call-claude","title":"Call Claude","text":"

To use claude simply type claude into the terminal and follow instructions.

claude\n
"},{"location":"v1/config/coder/#shell-environment","title":"Shell Environment","text":"

The .bashrc is configured to include the MkDocs virtual environment and user-local binaries in your PATH for convenience.

"},{"location":"v1/config/coder/#code-navigation-and-editing-features","title":"Code Navigation and Editing Features","text":"

The code-server environment provides robust code navigation and editing features, including:

"},{"location":"v1/config/coder/#collaboration-features","title":"Collaboration Features","text":"

Code-server includes features to support collaboration:

"},{"location":"v1/config/coder/#security-considerations","title":"Security Considerations","text":"

When using code-server, consider the following security aspects:

"},{"location":"v1/config/coder/#ollama-integration","title":"Ollama Integration","text":"

The code-server environment includes Ollama, a tool for running large language models locally on your machine.

"},{"location":"v1/config/coder/#what-is-ollama","title":"What is Ollama?","text":"

Ollama is a lightweight, extensible framework for building and running language models locally. It provides a simple API for creating, running, and managing models, making it easy to integrate AI capabilities into your development workflow without relying on external services.

"},{"location":"v1/config/coder/#getting-started-with-ollama","title":"Getting Started with Ollama","text":""},{"location":"v1/config/coder/#staring-ollama","title":"Staring Ollama","text":"

For ollama to be available, you need to open a terminal and run:

ollama serve\n

This will start the ollama server and you can then proceed to pulling a model and chatting.

"},{"location":"v1/config/coder/#pulling-a-model","title":"Pulling a Model","text":"

To get started, you'll need to pull a model. For development and testing, we recommend starting with a smaller model like Gemma 2B:

ollama pull gemma2:2b\n

For even lighter resource usage, you can use the 1B parameter version:

ollama pull gemma2:1b\n
"},{"location":"v1/config/coder/#running-a-model","title":"Running a Model","text":"

Once you've pulled a model, you can start an interactive session:

ollama run gemma2:2b\n
"},{"location":"v1/config/coder/#available-models","title":"Available Models","text":"

Popular models available through Ollama include:

"},{"location":"v1/config/coder/#using-ollama-in-your-development-workflow","title":"Using Ollama in Your Development Workflow","text":""},{"location":"v1/config/coder/#api-access","title":"API Access","text":"

Ollama provides a REST API that runs on http://localhost:11434 by default. You can integrate this into your applications:

curl http://localhost:11434/api/generate -d '{\n  \"model\": \"gemma2:2b\",\n  \"prompt\": \"Write a Python function to calculate fibonacci numbers\",\n  \"stream\": false\n}'\n
"},{"location":"v1/config/coder/#model-management","title":"Model Management","text":"

List installed models:

ollama list\n

Remove a model:

ollama rm gemma2:2b\n

Show model information:

ollama show gemma2:2b\n

"},{"location":"v1/config/coder/#resource-considerations","title":"Resource Considerations","text":""},{"location":"v1/config/coder/#integration-with-development-tools","title":"Integration with Development Tools","text":"

Ollama can be integrated with various development tools and editors through its API, enabling features like:

For more information, visit the Ollama documentation.

For more detailed information on configuring and using code-server, refer to the official code-server documentation.

"},{"location":"v1/config/map/","title":"Map Configuration","text":"

The Map system is a containerized web application that visualizes geographic data from NocoDB on an interactive map using Leaflet.js. It's designed for canvassing applications and community organizing.

"},{"location":"v1/config/map/#features","title":"Features","text":""},{"location":"v1/config/map/#setup-process-overview","title":"Setup Process Overview","text":"

The setup process involves several steps that must be completed in order:

  1. Get NocoDB API Token - Create an API token in your NocoDB instance
  2. Configure Environment - Update the .env file with your NocoDB details
  3. Auto-Create Database Structure - Run the build script to create required tables
  4. Get Table URLs - Find and copy the URLs for the newly created tables
  5. Update Environment with URLs - Add the table URLs to your .env file
  6. Build and Deploy - Build the Docker image and start the application
"},{"location":"v1/config/map/#prerequisites","title":"Prerequisites","text":""},{"location":"v1/config/map/#step-1-get-nocodb-api-token","title":"Step 1: Get NocoDB API Token","text":"
  1. Login to your NocoDB instance
  2. Click your user icon \u2192 Account Settings
  3. Go to the API Tokens tab
  4. Click Create new token
  5. Set the following permissions:
  6. Read: Yes
  7. Write: Yes
  8. Delete: Yes (optional, for admin functions)
  9. Copy the generated token - you'll need it for the next step

Token Security

Keep your API token secure and never commit it to version control. The token provides full access to your NocoDB data.

"},{"location":"v1/config/map/#step-2-configure-environment","title":"Step 2: Configure Environment","text":"

Edit the .env file in the map/ directory:

# NocoDB API Configuration\nNOCODB_API_URL=https://your-nocodb-instance.com/api/v1\nNOCODB_API_TOKEN=your-api-token-here\n\n# These URLs will be populated after running build-nocodb.sh\nNOCODB_VIEW_URL=\nNOCODB_LOGIN_SHEET=\nNOCODB_SETTINGS_SHEET=\n\n# Server Configuration\nPORT=3000\nNODE_ENV=production\n\n# Session Secret (generate with: openssl rand -hex 32)\nSESSION_SECRET=your-secure-random-string\n\n# Map Defaults (Edmonton, Alberta, Canada)\nDEFAULT_LAT=53.5461\nDEFAULT_LNG=-113.4938\nDEFAULT_ZOOM=11\n\n# Optional: Map Boundaries (prevents users from adding points outside area)\n# BOUND_NORTH=53.7\n# BOUND_SOUTH=53.4\n# BOUND_EAST=-113.3\n# BOUND_WEST=-113.7\n\n# Production Settings\nTRUST_PROXY=true\nCOOKIE_DOMAIN=.yourdomain.com\nALLOWED_ORIGINS=https://map.yourdomain.com,http://localhost:3000\n
"},{"location":"v1/config/map/#required-configuration","title":"Required Configuration","text":""},{"location":"v1/config/map/#optional-configuration","title":"Optional Configuration","text":""},{"location":"v1/config/map/#step-3-auto-create-database-structure","title":"Step 3: Auto-Create Database Structure","text":"

The build-nocodb.sh script will automatically create the required tables in your NocoDB instance.

cd map\nchmod +x build-nocodb.sh\n./build-nocodb.sh\n
"},{"location":"v1/config/map/#what-the-script-creates","title":"What the Script Creates","text":"

The script creates three tables with the following structure:

"},{"location":"v1/config/map/#1-locations-table","title":"1. Locations Table","text":"

Main table for storing map data:

"},{"location":"v1/config/map/#2-login-table","title":"2. Login Table","text":"

User authentication table:

"},{"location":"v1/config/map/#3-settings-table","title":"3. Settings Table","text":"

Admin configuration table:

"},{"location":"v1/config/map/#default-data","title":"Default Data","text":"

The script also creates: - A default admin user (admin@example.com) - A default start location setting

"},{"location":"v1/config/map/#step-4-get-table-urls","title":"Step 4: Get Table URLs","text":"

After the script completes successfully:

  1. Login to your NocoDB instance
  2. Navigate to your project (should be named \"Map Viewer Project\")
  3. For each table, get the view URL:
  4. Click on the table name
  5. Copy the URL from your browser's address bar
  6. The URL should look like: https://your-nocodb.com/dashboard/#/nc/project-id/table-id

You need URLs for: - Locations table \u2192 NOCODB_VIEW_URL - Login table \u2192 NOCODB_LOGIN_SHEET - Settings table \u2192 NOCODB_SETTINGS_SHEET

"},{"location":"v1/config/map/#step-5-update-environment-with-urls","title":"Step 5: Update Environment with URLs","text":"

Edit your .env file and add the table URLs:

# Update these with the actual URLs from your NocoDB instance\nNOCODB_VIEW_URL=https://your-nocodb.com/dashboard/#/nc/project-id/locations-table-id\nNOCODB_LOGIN_SHEET=https://your-nocodb.com/dashboard/#/nc/project-id/login-table-id\nNOCODB_SETTINGS_SHEET=https://your-nocodb.com/dashboard/#/nc/project-id/settings-table-id\n

URL Format

Make sure to use the complete dashboard URLs, not the API URLs. The application will automatically extract the project and table IDs from these URLs.

"},{"location":"v1/config/map/#step-6-build-and-deploy","title":"Step 6: Build and Deploy","text":"

Build the Docker image and start the application:

# Build the Docker image\ndocker-compose build\n\n# Start the application\ndocker-compose up -d\n
"},{"location":"v1/config/map/#verify-deployment","title":"Verify Deployment","text":"
  1. Check that the container is running:

    docker-compose ps\n

  2. Check the logs:

    docker-compose logs -f map-viewer\n

  3. Access the application at http://localhost:3000 (or your configured domain)

"},{"location":"v1/config/map/#using-the-map-system","title":"Using the Map System","text":""},{"location":"v1/config/map/#user-interface","title":"User Interface","text":""},{"location":"v1/config/map/#main-map-view","title":"Main Map View","text":""},{"location":"v1/config/map/#location-markers","title":"Location Markers","text":""},{"location":"v1/config/map/#adding-locations","title":"Adding Locations","text":"
  1. Click on the map where you want to add a location
  2. Fill out the form with contact information
  3. Select support level and sign information
  4. Add any relevant notes
  5. Click \"Save Location\"
"},{"location":"v1/config/map/#authentication","title":"Authentication","text":""},{"location":"v1/config/map/#user-login","title":"User Login","text":""},{"location":"v1/config/map/#admin-access","title":"Admin Access","text":""},{"location":"v1/config/map/#admin-panel-features","title":"Admin Panel Features","text":""},{"location":"v1/config/map/#start-location-configuration","title":"Start Location Configuration","text":""},{"location":"v1/config/map/#walk-sheet-generator","title":"Walk Sheet Generator","text":""},{"location":"v1/config/map/#api-endpoints","title":"API Endpoints","text":""},{"location":"v1/config/map/#public-endpoints","title":"Public Endpoints","text":""},{"location":"v1/config/map/#authentication-endpoints","title":"Authentication Endpoints","text":""},{"location":"v1/config/map/#admin-endpoints-requires-admin-privileges","title":"Admin Endpoints (requires admin privileges)","text":""},{"location":"v1/config/map/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/config/map/#common-issues","title":"Common Issues","text":""},{"location":"v1/config/map/#locations-not-showing","title":"Locations not showing","text":""},{"location":"v1/config/map/#cannot-add-locations","title":"Cannot add locations","text":""},{"location":"v1/config/map/#authentication-issues","title":"Authentication issues","text":""},{"location":"v1/config/map/#build-script-failures","title":"Build script failures","text":""},{"location":"v1/config/map/#development-mode","title":"Development Mode","text":"

For development and debugging:

cd map/app\nnpm install\nnpm run dev\n

This will start the application with hot reload and detailed logging.

"},{"location":"v1/config/map/#logs-and-monitoring","title":"Logs and Monitoring","text":"

View application logs:

docker-compose logs -f map-viewer\n

Check health status:

curl http://localhost:3000/health\n

"},{"location":"v1/config/map/#security-considerations","title":"Security Considerations","text":"
  1. API Token Security: Keep tokens secure and rotate regularly
  2. HTTPS: Use HTTPS in production
  3. CORS Configuration: Set appropriate ALLOWED_ORIGINS
  4. Cookie Security: Configure COOKIE_DOMAIN properly
  5. Input Validation: All inputs are validated server-side
  6. Rate Limiting: API endpoints have rate limiting
  7. Session Security: Use a strong SESSION_SECRET
"},{"location":"v1/config/map/#maintenance","title":"Maintenance","text":""},{"location":"v1/config/map/#regular-updates","title":"Regular Updates","text":"
# Stop the application\ndocker-compose down\n\n# Pull updates (if using git)\ngit pull origin main\n\n# Rebuild and restart\ndocker-compose build\ndocker-compose up -d\n
"},{"location":"v1/config/map/#backup-considerations","title":"Backup Considerations","text":""},{"location":"v1/config/map/#performance-tips","title":"Performance Tips","text":""},{"location":"v1/config/map/#support","title":"Support","text":"

For issues or questions: 1. Check the troubleshooting section above 2. Review NocoDB documentation 3. Check Docker and Docker Compose documentation 4. Open an issue on GitHub

"},{"location":"v1/config/mkdocs/","title":"MkDocs Customization & Features Overview","text":"

BNKops has been building our own features, widgets, and css styles for MKdocs material theme.

This document explains the custom styling, repository widgets, and key features enabled in this MkDocs site.

For more info on how to build your site see Site Build

"},{"location":"v1/config/mkdocs/#using-the-repository-widget-in-documentation","title":"Using the Repository Widget in Documentation","text":"

You can embed repository widgets directly in your Markdown documentation to display live repository stats and metadata. To do this, add a div with the appropriate class and data-repo attribute for the repository you want to display.

Example (for a Gitea repository):

<div class=\"gitea-widget\" data-repo=\"admin/changemaker.lite\"></div>\n

This will render a styled card with information about the admin/changemaker.lite repository:

Options: You can control the widget display with additional data attributes: - data-show-description=\"false\" \u2014 Hide the description - data-show-language=\"false\" \u2014 Hide the language - data-show-last-update=\"false\" \u2014 Hide the last update date

Example with options:

<div class=\"gitea-widget\" data-repo=\"admin/changemaker.lite\" data-show-description=\"false\"></div>\n

For GitHub repositories, use the github-widget class:

<div class=\"github-widget\" data-repo=\"lyqht/mini-qr\"></div>\n

"},{"location":"v1/config/mkdocs/#custom-css-styling-stylesheetsextracss","title":"Custom CSS Styling (stylesheets/extra.css)","text":"

The extra.css file provides extensive custom styling for the site, including:

"},{"location":"v1/config/mkdocs/#repository-widgets","title":"Repository Widgets","text":""},{"location":"v1/config/mkdocs/#data-generation-hooksrepo_widget_hookpy","title":"Data Generation (hooks/repo_widget_hook.py)","text":""},{"location":"v1/config/mkdocs/#github-widget-javascriptsgithub-widgetjs","title":"GitHub Widget (javascripts/github-widget.js)","text":""},{"location":"v1/config/mkdocs/#gitea-widget-javascriptsgitea-widgetjs","title":"Gitea Widget (javascripts/gitea-widget.js)","text":""},{"location":"v1/config/mkdocs/#mkdocs-features-mkdocsyml","title":"MkDocs Features (mkdocs.yml)","text":"

Key features and plugins enabled:

"},{"location":"v1/config/mkdocs/#summary","title":"Summary","text":"

This MkDocs site is highly customized for developer documentation, with visually rich repository widgets, improved code and table rendering, and a modern, responsive UI. All repository stats are fetched at build time for performance and reliability.

"},{"location":"v1/manual/","title":"Manuals","text":"

The following are manuals, some accompanied by videos, on the use of the system.

"},{"location":"v1/manual/map/","title":"Map System Manual","text":"

This comprehensive manual covers all features of the Map System - a powerful campaign management platform with interactive mapping, volunteer coordination, data management, and communication tools. (Insert screenshot - feature overview)

"},{"location":"v1/manual/map/#1-getting-started","title":"1. Getting Started","text":""},{"location":"v1/manual/map/#logging-in","title":"Logging In","text":"
  1. Go to your map site URL (e.g., https://yoursite.com or http://localhost:3000).
  2. Enter your email and password on the login page.
  3. Click Login.
  4. If you forget your password, use the Reset Password link or contact an admin.
  5. Password Recovery: Check your email for reset instructions if SMTP is configured. (Insert screenshot - login page)
"},{"location":"v1/manual/map/#user-types-permissions","title":"User Types & Permissions","text":""},{"location":"v1/manual/map/#2-interactive-map-features","title":"2. Interactive Map Features","text":""},{"location":"v1/manual/map/#basic-map-navigation","title":"Basic Map Navigation","text":"
  1. After login, you'll see the interactive map with location markers.
  2. Use mouse or touch to pan and zoom around the map.
  3. Your current location may appear as a blue dot (if location services enabled).
  4. Use the zoom controls (\u00b1) or mouse wheel to adjust map scale. (Insert screenshot - main map view)
"},{"location":"v1/manual/map/#advanced-search-ctrlk","title":"Advanced Search (Ctrl+K)","text":"
  1. Press Ctrl+K anywhere on the site to open the universal search.
  2. Search for:
  3. Addresses: Find and navigate to specific locations
  4. Documentation: Search help articles and guides
  5. Locations: Find existing data points by name or details
  6. Click results to navigate directly to locations on the map.
  7. QR Code Generation: Search results include QR codes for easy mobile sharing. (Insert screenshot - search interface)
"},{"location":"v1/manual/map/#map-overlays-cuts","title":"Map Overlays (Cuts)","text":"
  1. Public Cuts: Geographic overlays (wards, neighborhoods, districts) are automatically displayed.
  2. Cut Selector: Use the multi-select dropdown to show/hide different cuts.
  3. Mobile Interface: On mobile, tap the \ud83d\uddfa\ufe0f button to manage overlays.
  4. Legend: View active cuts with color coding and labels.
  5. Cuts help organize and filter location data by geographic regions. (Insert screenshot - cuts interface)
"},{"location":"v1/manual/map/#3-location-management","title":"3. Location Management","text":""},{"location":"v1/manual/map/#adding-new-locations","title":"Adding New Locations","text":"
  1. Click the Add Location button (+ icon) on the map.
  2. Click on the map where you want to place the new location.
  3. Fill out the comprehensive form:
  4. Personal: First Name, Last Name, Email, Phone, Unit Number
  5. Political: Support Level (1-4 scale), Party Affiliation
  6. Address: Street Address (auto-geocoded when possible)
  7. Campaign: Lawn Sign (Yes/No/Maybe), Sign Size, Volunteer Interest
  8. Notes: Additional information and comments
  9. Address Confirmation: System validates and confirms addresses when possible.
  10. Click Save to add the location marker. (Insert screenshot - add location form)
"},{"location":"v1/manual/map/#editing-and-managing-locations","title":"Editing and Managing Locations","text":"
  1. Click on any location marker to view details.
  2. Popup Actions:
  3. Edit: Modify all location details
  4. Move: Drag marker to new position (admin/user only)
  5. Delete: Remove location (admin/user only - hidden for temp users)
  6. Quick Actions: Email, phone, or text contact directly from popup.
  7. Support Level Color Coding: Markers change color based on support level.
  8. Apartment View: Special clustering for apartment buildings. (Insert screenshot - location popup)
"},{"location":"v1/manual/map/#bulk-data-import","title":"Bulk Data Import","text":"
  1. Admin Panel \u2192 Data Converter \u2192 Upload CSV
  2. Supported Formats: CSV files with address data
  3. Batch Geocoding: Automatically converts addresses to coordinates
  4. Progress Tracking: Visual progress bar with success/failure reporting
  5. Error Handling: Downloadable error reports for failed geocoding
  6. Validation: Preview and verify data before final import
  7. Edmonton Data: Pre-configured for City of Edmonton neighborhood data. (Insert screenshot - data import interface)
"},{"location":"v1/manual/map/#4-volunteer-shift-management","title":"4. Volunteer Shift Management","text":""},{"location":"v1/manual/map/#public-shift-signup-no-login-required","title":"Public Shift Signup (No Login Required)","text":"
  1. Visit the Public Shifts page (accessible without account).
  2. Browse available volunteer opportunities with:
  3. Date, time, and location information
  4. Available spots and current signups
  5. Detailed shift descriptions
  6. One-Click Signup:
  7. Enter name, email, and phone number
  8. Automatic temporary account creation
  9. Instant email confirmation with login details
  10. Account Expiration: Temp accounts automatically expire after shift date. (Insert screenshot - public shifts page)
"},{"location":"v1/manual/map/#authenticated-user-shift-management","title":"Authenticated User Shift Management","text":"
  1. Go to Shifts from the main navigation.
  2. View Options:
  3. Grid View: List format with detailed information
  4. Calendar View: Monthly calendar with shift visualization
  5. Filter Options: Date range, shift type, and availability status.
  6. My Signups: View your confirmed shifts at the top of the page.
"},{"location":"v1/manual/map/#shift-actions","title":"Shift Actions","text":""},{"location":"v1/manual/map/#5-advanced-map-features","title":"5. Advanced Map Features","text":""},{"location":"v1/manual/map/#geographic-cuts-system","title":"Geographic Cuts System","text":"

What are Cuts?: Polygon overlays that define geographic regions like wards, neighborhoods, or custom areas.

"},{"location":"v1/manual/map/#viewing-cuts-all-users","title":"Viewing Cuts (All Users)","text":"
  1. Auto-Display: Public cuts appear automatically when map loads.
  2. Multi-Select Control: Desktop users see dropdown with checkboxes for each cut.
  3. Mobile Modal: Touch the \ud83d\uddfa\ufe0f button for full-screen cut management.
  4. Quick Actions: \"Show All\" / \"Hide All\" buttons for easy control.
  5. Color Coding: Each cut has unique colors and opacity settings. (Insert screenshot - cuts display)
"},{"location":"v1/manual/map/#admin-cut-management","title":"Admin Cut Management","text":"
  1. Admin Panel \u2192 Map Cuts for full management interface.
  2. Drawing Tools: Click-to-add-points polygon creation system.
  3. Cut Properties:
  4. Name, description, and category
  5. Color and opacity customization
  6. Public visibility settings
  7. Official designation markers
  8. Cut Operations:
  9. Create, edit, duplicate, and delete cuts
  10. Import/export cut data as JSON
  11. Location filtering within cut boundaries
  12. Statistics Dashboard: Analyze location data within cut boundaries.
  13. Print Functionality: Generate professional reports with maps and data tables. (Insert screenshot - cut management)
"},{"location":"v1/manual/map/#location-filtering-within-cuts","title":"Location Filtering within Cuts","text":"
  1. View Cut: Select a cut from the admin interface.
  2. Filter Locations: Automatically shows only locations within cut boundaries.
  3. Statistics Panel: Real-time counts of:
  4. Total locations within cut
  5. Support level breakdown (Strong/Lean/Undecided/Opposition)
  6. Contact information availability (email/phone)
  7. Lawn sign placements
  8. Export Options: Download filtered location data as CSV.
  9. Print Reports: Generate professional cut reports with statistics and location tables. (Insert screenshot - cut filtering)
"},{"location":"v1/manual/map/#6-communication-tools","title":"6. Communication Tools","text":""},{"location":"v1/manual/map/#universal-search-contact","title":"Universal Search & Contact","text":"
  1. Ctrl+K Search: Find and contact anyone in your database instantly.
  2. Direct Contact Links: Email and phone links throughout the interface.
  3. QR Code Generation: Share contact information via QR codes.
"},{"location":"v1/manual/map/#admin-communication-features","title":"Admin Communication Features","text":"
  1. Bulk Email System:
  2. Rich HTML email composer with formatting toolbar
  3. Live email preview before sending
  4. Broadcast to all users with progress tracking
  5. Individual delivery status for each recipient
  6. One-Click Communication Buttons:
  7. \ud83d\udce7 Email: Launch email client with pre-filled recipient
  8. \ud83d\udcde Call: Open phone dialer with contact's number
  9. \ud83d\udcac SMS: Launch text messaging with contact's number
  10. Shift Communication:
  11. Email shift details to all volunteers
  12. Individual volunteer contact from shift management
  13. Automated signup confirmations and reminders. (Insert screenshot - communication tools)
"},{"location":"v1/manual/map/#7-walk-sheet-generator","title":"7. Walk Sheet Generator","text":""},{"location":"v1/manual/map/#creating-walk-sheets","title":"Creating Walk Sheets","text":"
  1. Admin Panel \u2192 Walk Sheet Generator
  2. Configuration Options:
  3. Title, subtitle, and footer text
  4. Contact information and instructions
  5. QR codes for digital resources
  6. Logo and branding elements
  7. Location Selection: Choose specific areas or use cut boundaries.
  8. Print Options: Multiple layout formats for different campaign needs.
  9. QR Integration: Add QR codes linking to:
  10. Digital surveys or forms
  11. Contact information
  12. Campaign websites or resources. (Insert screenshot - walk sheet generator)
"},{"location":"v1/manual/map/#mobile-optimized-walk-sheets","title":"Mobile-Optimized Walk Sheets","text":"
  1. Responsive Design: Optimized for viewing on phones and tablets.
  2. QR Code Scanner Integration: Quick scanning for volunteer check-ins.
  3. Offline Capability: Download for use without internet connection.
"},{"location":"v1/manual/map/#8-user-profile-management","title":"8. User Profile Management","text":""},{"location":"v1/manual/map/#personal-settings","title":"Personal Settings","text":"
  1. User Menu \u2192 Profile to access personal settings.
  2. Account Information:
  3. Update name, email, and phone number
  4. Change password
  5. Communication preferences
  6. Activity History: View your shift signups and location contributions.
  7. Privacy Settings: Control data sharing and communication preferences. (Insert screenshot - user profile)
"},{"location":"v1/manual/map/#password-recovery","title":"Password Recovery","text":"
  1. Forgot Password link on login page.
  2. Email Reset: Automated password reset via SMTP (if configured).
  3. Admin Assistance: Contact administrators for manual password resets.
"},{"location":"v1/manual/map/#9-admin-panel-features","title":"9. Admin Panel Features","text":""},{"location":"v1/manual/map/#dashboard-overview","title":"Dashboard Overview","text":"
  1. System Statistics: User counts, recent activity, and system health.
  2. Quick Actions: Direct access to common administrative tasks.
  3. NocoDB Integration: Direct links to database management interface. (Insert screenshot - admin dashboard)
"},{"location":"v1/manual/map/#user-management","title":"User Management","text":"
  1. Create Users: Add new accounts with role assignments:
  2. Regular Users: Full access to mapping and shifts
  3. Temporary Users: Limited access with automatic expiration
  4. Admin Users: Full system administration privileges
  5. User Communication:
  6. Send login details to new users
  7. Bulk email all users with rich HTML composer
  8. Individual user contact (email, call, text)
  9. User Types & Expiration:
  10. Set expiration dates for temporary accounts
  11. Visual indicators for user types and status
  12. Automatic cleanup of expired accounts. (Insert screenshot - user management)
"},{"location":"v1/manual/map/#shift-administration","title":"Shift Administration","text":"
  1. Create & Manage Shifts:
  2. Set dates, times, locations, and volunteer limits
  3. Public/private visibility settings
  4. Detailed descriptions and requirements
  5. Volunteer Management:
  6. Add users directly to shifts
  7. Remove volunteers when needed
  8. Email shift details to all participants
  9. Generate public signup links
  10. Volunteer Communication:
  11. Individual contact buttons (email, call, text) for each volunteer
  12. Bulk shift detail emails with delivery tracking
  13. Automated confirmation and reminder systems. (Insert screenshot - shift management)
"},{"location":"v1/manual/map/#system-configuration","title":"System Configuration","text":"
  1. Map Settings:
  2. Set default start location and zoom level
  3. Configure map boundaries and restrictions
  4. Customize marker styles and colors
  5. Integration Management:
  6. NocoDB database connections
  7. Listmonk email list synchronization
  8. SMTP configuration for automated emails
  9. Security Settings:
  10. User permissions and role management
  11. API access controls
  12. Session management. (Insert screenshot - system config)
"},{"location":"v1/manual/map/#10-data-management-integration","title":"10. Data Management & Integration","text":""},{"location":"v1/manual/map/#nocodb-database-integration","title":"NocoDB Database Integration","text":"
  1. Direct Database Access: Admin links to NocoDB sheets for advanced data management.
  2. Automated Sync: Real-time synchronization between map interface and database.
  3. Backup & Migration: Built-in tools for data backup and system migration.
  4. Custom Fields: Add custom data fields through NocoDB interface.
"},{"location":"v1/manual/map/#listmonk-email-marketing-integration","title":"Listmonk Email Marketing Integration","text":"
  1. Automatic List Sync: Map data automatically syncs to Listmonk email lists.
  2. Segmentation: Create targeted lists based on:
  3. Geographic location (cuts/neighborhoods)
  4. Support levels and volunteer interest
  5. Contact preferences and activity
  6. One-Direction Sync: Maintains data integrity while allowing email unsubscribes.
  7. Compliance: Newsletter legislation compliance with opt-out capabilities. (Insert screenshot - integration settings)
"},{"location":"v1/manual/map/#data-export-reporting","title":"Data Export & Reporting","text":"
  1. CSV Export: Download location data, user lists, and shift reports.
  2. Cut Reports: Professional reports with statistics and location breakdowns.
  3. Print-Ready Formats: Optimized layouts for physical distribution.
  4. Analytics Dashboard: Track user engagement and system usage.
"},{"location":"v1/manual/map/#11-mobile-accessibility-features","title":"11. Mobile & Accessibility Features","text":""},{"location":"v1/manual/map/#mobile-optimized-interface","title":"Mobile-Optimized Interface","text":"
  1. Responsive Design: Fully functional on phones and tablets.
  2. Touch Navigation: Optimized touch controls for map interaction.
  3. Mobile-Specific Features:
  4. Cut management modal for overlay control
  5. Simplified navigation and larger touch targets
  6. Offline capability for basic functions
"},{"location":"v1/manual/map/#accessibility","title":"Accessibility","text":"
  1. Keyboard Navigation: Full keyboard support throughout the interface.
  2. Screen Reader Compatibility: ARIA labels and semantic markup.
  3. High Contrast Support: Compatible with accessibility themes.
  4. Text Scaling: Responsive to browser zoom and text size settings.
"},{"location":"v1/manual/map/#12-security-privacy","title":"12. Security & Privacy","text":""},{"location":"v1/manual/map/#data-protection","title":"Data Protection","text":"
  1. Server-Side Security: All API tokens and credentials kept server-side only.
  2. Input Validation: Comprehensive validation and sanitization of all user inputs.
  3. CORS Protection: Cross-origin request security measures.
  4. Rate Limiting: Protection against abuse and automated attacks.
"},{"location":"v1/manual/map/#user-privacy","title":"User Privacy","text":"
  1. Role-Based Access: Users only see data appropriate to their permission level.
  2. Temporary Account Expiration: Automatic cleanup of temporary user data.
  3. Audit Trails: Logging of administrative actions and data changes.
  4. Data Retention: Configurable retention policies for different data types. (Insert screenshot - security settings)
"},{"location":"v1/manual/map/#authentication","title":"Authentication","text":"
  1. Secure Login: Password-based authentication with optional 2FA.
  2. Session Management: Automatic logout for expired sessions.
  3. Password Policies: Configurable password strength requirements.
  4. Account Lockout: Protection against brute force attacks.
"},{"location":"v1/manual/map/#13-performance-system-requirements","title":"13. Performance & System Requirements","text":""},{"location":"v1/manual/map/#system-performance","title":"System Performance","text":"
  1. Optimized Database Queries: Reduced API calls by over 5000% for better performance.
  2. Smart Caching: Intelligent caching of frequently accessed data.
  3. Progressive Loading: Map data loads incrementally for faster initial page loads.
  4. Background Sync: Automatic data synchronization without blocking user interface.
"},{"location":"v1/manual/map/#browser-requirements","title":"Browser Requirements","text":"
  1. Modern Browsers: Chrome, Firefox, Safari, Edge (recent versions).
  2. JavaScript Required: Full functionality requires JavaScript enabled.
  3. Local Storage: Uses browser storage for session management and caching.
  4. Geolocation: Optional location services for enhanced functionality.
"},{"location":"v1/manual/map/#14-troubleshooting","title":"14. Troubleshooting","text":""},{"location":"v1/manual/map/#common-issues","title":"Common Issues","text":""},{"location":"v1/manual/map/#performance-issues","title":"Performance Issues","text":""},{"location":"v1/manual/map/#mobile-issues","title":"Mobile Issues","text":""},{"location":"v1/manual/map/#15-advanced-features","title":"15. Advanced Features","text":""},{"location":"v1/manual/map/#api-access","title":"API Access","text":"
  1. RESTful API: Programmatic access to map data and functionality.
  2. Authentication: Token-based API authentication for external integrations.
  3. Rate Limiting: API usage limits to ensure system stability.
  4. Documentation: Complete API documentation for developers.
"},{"location":"v1/manual/map/#customization-options","title":"Customization Options","text":"
  1. Theming: Customizable color schemes and branding.
  2. Field Configuration: Add custom data fields through admin interface.
  3. Workflow Customization: Configurable user workflows and permissions.
  4. Integration Hooks: Webhook support for external system integration.
"},{"location":"v1/manual/map/#16-getting-help-support","title":"16. Getting Help & Support","text":""},{"location":"v1/manual/map/#built-in-help","title":"Built-in Help","text":"
  1. Context Help: Tooltips and help text throughout the interface.
  2. Search Documentation: Use Ctrl+K to search help articles and guides.
  3. Status Messages: Clear feedback for all user actions and system status.
"},{"location":"v1/manual/map/#administrator-support","title":"Administrator Support","text":"
  1. Contact Admin: Use the contact information provided during setup.
  2. System Logs: Administrators have access to detailed system logs for troubleshooting.
  3. Database Direct Access: Admins can access NocoDB directly for advanced data management.
"},{"location":"v1/manual/map/#community-resources","title":"Community Resources","text":"
  1. Documentation: Comprehensive online documentation and guides.
  2. GitHub Repository: Access to source code and issue tracking.
  3. Developer Community: Active community for advanced customization and development.

For technical support, contact your system administrator or refer to the comprehensive documentation available through the help system. (Insert screenshot - help resources)

"},{"location":"v1/services/","title":"Services","text":"

Changemaker Lite includes several powerful services that work together to provide a complete documentation and development platform. Each service is containerized and can be accessed through its dedicated port.

"},{"location":"v1/services/#available-services","title":"Available Services","text":""},{"location":"v1/services/#code-server","title":"Code Server","text":"

Port: 8888 | Visual Studio Code in your browser for remote development

"},{"location":"v1/services/#listmonk","title":"Listmonk","text":"

Port: 9000 | Self-hosted newsletter and mailing list manager

"},{"location":"v1/services/#postgresql","title":"PostgreSQL","text":"

Port: 5432 | Reliable database backend - Data persistence for Listmonk - ACID compliance - High performance - Backup and restore capabilities

"},{"location":"v1/services/#mkdocs-material","title":"MkDocs Material","text":"

Port: 4000 | Documentation site generator with live preview

"},{"location":"v1/services/#static-site-server","title":"Static Site Server","text":"

Port: 4001 | Nginx-powered static site hosting - High-performance serving - Built documentation hosting - Caching and compression - Security headers

"},{"location":"v1/services/#n8n","title":"n8n","text":"

Port: 5678 | Workflow automation tool

"},{"location":"v1/services/#nocodb","title":"NocoDB","text":"

Port: 8090 | No-code database platform

"},{"location":"v1/services/#homepage","title":"Homepage","text":"

Port: 3010 | Modern dashboard for all services

"},{"location":"v1/services/#gitea","title":"Gitea","text":"

Port: 3030 | Self-hosted Git service

"},{"location":"v1/services/#mini-qr","title":"Mini QR","text":"

Port: 8089 | Simple QR code generator service

"},{"location":"v1/services/#map","title":"Map","text":"

Port: 3000 | Canvassing and community organizing application

"},{"location":"v1/services/#service-architecture","title":"Service Architecture","text":"
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510    \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510    \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502   Homepage      \u2502    \u2502   Code Server   \u2502    \u2502     MkDocs      \u2502\n\u2502     :3010       \u2502    \u2502     :8888       \u2502    \u2502     :4000       \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518    \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518    \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510    \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510    \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Static Server   \u2502    \u2502    Listmonk     \u2502    \u2502      n8n        \u2502\n\u2502     :4001       \u2502    \u2502     :9000       \u2502    \u2502     :5678       \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518    \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518    \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510    \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510    \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502     NocoDB      \u2502    \u2502 PostgreSQL      \u2502    \u2502 PostgreSQL      \u2502\n\u2502     :8090       \u2502    \u2502 (listmonk-db)   \u2502    \u2502 (root_db)       \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518    \u2502     :5432       \u2502    \u2502     :5432       \u2502\n                      \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518    \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502      Map        \u2502\n\u2502     :3000       \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n
"},{"location":"v1/services/code-server/","title":"Code Server","text":""},{"location":"v1/services/code-server/#overview","title":"Overview","text":"

Code Server provides a full Visual Studio Code experience in your web browser, allowing you to develop from any device. It runs on your server and provides access to your development environment through a web interface.

"},{"location":"v1/services/code-server/#features","title":"Features","text":""},{"location":"v1/services/code-server/#access","title":"Access","text":""},{"location":"v1/services/code-server/#configuration","title":"Configuration","text":""},{"location":"v1/services/code-server/#environment-variables","title":"Environment Variables","text":""},{"location":"v1/services/code-server/#volumes","title":"Volumes","text":""},{"location":"v1/services/code-server/#usage","title":"Usage","text":"
  1. Access Code Server at http://localhost:8888
  2. Open the /home/coder/mkdocs/ workspace
  3. Start editing your documentation files
  4. Install extensions as needed
  5. Use the integrated terminal for commands
"},{"location":"v1/services/code-server/#useful-extensions","title":"Useful Extensions","text":"

Consider installing these extensions for better documentation work:

"},{"location":"v1/services/code-server/#official-documentation","title":"Official Documentation","text":"

For more detailed information, visit the official Code Server documentation.

"},{"location":"v1/services/gitea/","title":"Gitea","text":"

Self-hosted Git service for collaborative development.

"},{"location":"v1/services/gitea/#overview","title":"Overview","text":"

Gitea is a lightweight, self-hosted Git service similar to GitHub, GitLab, and Bitbucket. It provides a web interface for managing repositories, issues, pull requests, and more.

"},{"location":"v1/services/gitea/#features","title":"Features","text":""},{"location":"v1/services/gitea/#access","title":"Access","text":""},{"location":"v1/services/gitea/#configuration","title":"Configuration","text":""},{"location":"v1/services/gitea/#environment-variables","title":"Environment Variables","text":""},{"location":"v1/services/gitea/#volumes","title":"Volumes","text":""},{"location":"v1/services/gitea/#usage","title":"Usage","text":"
  1. Access Gitea at http://localhost:${GITEA_WEB_PORT:-3030}
  2. Register or log in as an admin user
  3. Create or import repositories
  4. Collaborate with your team
"},{"location":"v1/services/gitea/#official-documentation","title":"Official Documentation","text":"

For more details, visit the official Gitea documentation.

"},{"location":"v1/services/homepage/","title":"Homepage","text":"

Modern dashboard for accessing all your self-hosted services.

"},{"location":"v1/services/homepage/#overview","title":"Overview","text":"

Homepage is a modern, fully static, fast, secure fully configurable application dashboard with integrations for over 100 services. It provides a beautiful and customizable interface to access all your Changemaker Lite services from a single location.

"},{"location":"v1/services/homepage/#features","title":"Features","text":""},{"location":"v1/services/homepage/#access","title":"Access","text":""},{"location":"v1/services/homepage/#configuration","title":"Configuration","text":""},{"location":"v1/services/homepage/#environment-variables","title":"Environment Variables","text":""},{"location":"v1/services/homepage/#configuration-files","title":"Configuration Files","text":"

Homepage uses YAML configuration files located in ./configs/homepage/:

"},{"location":"v1/services/homepage/#volumes","title":"Volumes","text":""},{"location":"v1/services/homepage/#changemaker-lite-services","title":"Changemaker Lite Services","text":"

Homepage is pre-configured with all Changemaker Lite services:

"},{"location":"v1/services/homepage/#essential-tools","title":"Essential Tools","text":""},{"location":"v1/services/homepage/#content-documentation","title":"Content & Documentation","text":""},{"location":"v1/services/homepage/#automation-data","title":"Automation & Data","text":""},{"location":"v1/services/homepage/#customization","title":"Customization","text":""},{"location":"v1/services/homepage/#adding-custom-services","title":"Adding Custom Services","text":"

Edit configs/homepage/services.yaml to add new services:

- Custom Category:\n    - My Service:\n        href: http://localhost:8080\n        description: Custom service description\n        icon: mdi-application\n        widget:\n          type: ping\n          url: http://localhost:8080\n
"},{"location":"v1/services/homepage/#custom-icons","title":"Custom Icons","text":"

Add custom icons to ./assets/icons/ directory and reference them in services.yaml:

icon: /icons/my-custom-icon.png\n
"},{"location":"v1/services/homepage/#themes-and-styling","title":"Themes and Styling","text":"

Modify configs/homepage/settings.yaml to customize appearance:

theme: dark  # or light\ncolor: purple  # slate, gray, zinc, neutral, stone, red, orange, amber, yellow, lime, green, emerald, teal, cyan, sky, blue, indigo, violet, purple, fuchsia, pink, rose\n
"},{"location":"v1/services/homepage/#widgets","title":"Widgets","text":"

Enable live monitoring widgets in configs/homepage/services.yaml:

- Service Name:\n    widget:\n      type: docker\n      container: container-name\n      server: my-docker\n
"},{"location":"v1/services/homepage/#service-monitoring","title":"Service Monitoring","text":"

Homepage can display real-time status information for your services:

"},{"location":"v1/services/homepage/#docker-integration","title":"Docker Integration","text":"

Homepage monitors Docker containers automatically when configured:

  1. Ensure Docker socket is mounted (/var/run/docker.sock)
  2. Configure container mappings in docker.yaml
  3. Add widget configurations to services.yaml
"},{"location":"v1/services/homepage/#security-considerations","title":"Security Considerations","text":""},{"location":"v1/services/homepage/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/services/homepage/#common-issues","title":"Common Issues","text":"

Configuration not loading: Check YAML syntax in configuration files

docker logs homepage-changemaker\n

Icons not displaying: Verify icon paths and file permissions

ls -la ./assets/icons/\n

Services not reachable: Verify network connectivity between containers

docker exec homepage-changemaker ping service-name\n

Widget data not updating: Check Docker socket permissions and container access

docker exec homepage-changemaker ls -la /var/run/docker.sock\n
"},{"location":"v1/services/homepage/#configuration-examples","title":"Configuration Examples","text":""},{"location":"v1/services/homepage/#basic-service-widget","title":"Basic Service Widget","text":"
- Code Server:\n    href: http://localhost:8888\n    description: VS Code in the browser\n    icon: code-server\n    widget:\n      type: docker\n      container: code-server-changemaker\n
"},{"location":"v1/services/homepage/#custom-dashboard-layout","title":"Custom Dashboard Layout","text":"
# settings.yaml\nlayout:\n  style: columns\n  columns: 3\n\n# Responsive breakpoints\nresponsive:\n  mobile: 1\n  tablet: 2\n  desktop: 3\n
"},{"location":"v1/services/homepage/#official-documentation","title":"Official Documentation","text":"

For comprehensive configuration guides and advanced features:

"},{"location":"v1/services/listmonk/","title":"Listmonk","text":"

Self-hosted newsletter and mailing list manager.

"},{"location":"v1/services/listmonk/#overview","title":"Overview","text":"

Listmonk is a modern, feature-rich newsletter and mailing list manager designed for high performance and easy management. It provides a complete solution for email campaigns, subscriber management, and analytics.

"},{"location":"v1/services/listmonk/#features","title":"Features","text":""},{"location":"v1/services/listmonk/#access","title":"Access","text":""},{"location":"v1/services/listmonk/#configuration","title":"Configuration","text":""},{"location":"v1/services/listmonk/#environment-variables","title":"Environment Variables","text":""},{"location":"v1/services/listmonk/#database","title":"Database","text":"

Listmonk uses PostgreSQL as its backend database. The database is automatically configured through the docker-compose setup.

"},{"location":"v1/services/listmonk/#uploads","title":"Uploads","text":""},{"location":"v1/services/listmonk/#getting-started","title":"Getting Started","text":"
  1. Access Listmonk at http://localhost:9000
  2. Log in with your admin credentials
  3. Set up your first mailing list
  4. Configure SMTP settings for sending emails
  5. Import subscribers or create subscription forms
  6. Create your first campaign
"},{"location":"v1/services/listmonk/#important-notes","title":"Important Notes","text":""},{"location":"v1/services/listmonk/#official-documentation","title":"Official Documentation","text":"

For comprehensive guides and API documentation, visit: - Listmonk Documentation - GitHub Repository

"},{"location":"v1/services/map/","title":"Map","text":"

Interactive map service for geospatial data visualization, powered by NocoDB and Leaflet.js.

"},{"location":"v1/services/map/#overview","title":"Overview","text":"

The Map service provides an interactive web-based map for displaying, searching, and analyzing geospatial data from a NocoDB backend. It supports real-time geolocation, adding new locations, and is optimized for both desktop and mobile use.

"},{"location":"v1/services/map/#features","title":"Features","text":""},{"location":"v1/services/map/#access","title":"Access","text":""},{"location":"v1/services/map/#configuration","title":"Configuration","text":"

All configuration is done via environment variables:

Variable Description Default NOCODB_API_URL NocoDB API base URL Required NOCODB_API_TOKEN API authentication token Required NOCODB_VIEW_URL Full NocoDB view URL Required PORT Server port 3000 DEFAULT_LAT Default map latitude 53.5461 DEFAULT_LNG Default map longitude -113.4938 DEFAULT_ZOOM Default map zoom level 11"},{"location":"v1/services/map/#volumes","title":"Volumes","text":""},{"location":"v1/services/map/#usage","title":"Usage","text":"
  1. Access the map at http://localhost:${MAP_PORT:-3000}
  2. Search for locations or addresses
  3. Add or view custom markers
  4. Analyze geospatial data as needed
"},{"location":"v1/services/map/#nocodb-table-setup","title":"NocoDB Table Setup","text":""},{"location":"v1/services/map/#required-columns","title":"Required Columns","text":""},{"location":"v1/services/map/#form-fields-as-seen-in-the-interface","title":"Form Fields (as seen in the interface)","text":""},{"location":"v1/services/map/#api-endpoints","title":"API Endpoints","text":""},{"location":"v1/services/map/#security-considerations","title":"Security Considerations","text":""},{"location":"v1/services/map/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/services/mini-qr/","title":"Mini QR","text":"

Simple QR code generator service.

"},{"location":"v1/services/mini-qr/#overview","title":"Overview","text":"

Mini QR is a lightweight service for generating QR codes for URLs, text, or other data. It provides a web interface for quick QR code creation and download.

"},{"location":"v1/services/mini-qr/#features","title":"Features","text":""},{"location":"v1/services/mini-qr/#access","title":"Access","text":""},{"location":"v1/services/mini-qr/#configuration","title":"Configuration","text":""},{"location":"v1/services/mini-qr/#environment-variables","title":"Environment Variables","text":""},{"location":"v1/services/mini-qr/#volumes","title":"Volumes","text":""},{"location":"v1/services/mini-qr/#usage","title":"Usage","text":"
  1. Access Mini QR at http://localhost:${MINI_QR_PORT:-8089}
  2. Enter the text or URL to encode
  3. Download or share the generated QR code
"},{"location":"v1/services/mkdocs/","title":"MkDocs Material","text":"

Modern documentation site generator with live preview.

Looking for more info on BNKops code-server integration?

\u2192 Code Server Configuration

"},{"location":"v1/services/mkdocs/#overview","title":"Overview","text":"

MkDocs Material is a powerful documentation framework built on top of MkDocs, providing a beautiful Material Design theme and advanced features for creating professional documentation sites.

"},{"location":"v1/services/mkdocs/#features","title":"Features","text":""},{"location":"v1/services/mkdocs/#access","title":"Access","text":""},{"location":"v1/services/mkdocs/#configuration","title":"Configuration","text":""},{"location":"v1/services/mkdocs/#main-configuration","title":"Main Configuration","text":"

Configuration is managed through mkdocs.yml in the project root.

"},{"location":"v1/services/mkdocs/#volumes","title":"Volumes","text":""},{"location":"v1/services/mkdocs/#environment-variables","title":"Environment Variables","text":""},{"location":"v1/services/mkdocs/#directory-structure","title":"Directory Structure","text":"
mkdocs/\n\u251c\u2500\u2500 mkdocs.yml          # Configuration file\n\u251c\u2500\u2500 docs/               # Documentation source\n\u2502   \u251c\u2500\u2500 index.md       # Homepage\n\u2502   \u251c\u2500\u2500 services/      # Service documentation\n\u2502   \u251c\u2500\u2500 blog/          # Blog posts\n\u2502   \u2514\u2500\u2500 overrides/     # Template overrides\n\u2514\u2500\u2500 site/              # Built static site\n
"},{"location":"v1/services/mkdocs/#writing-documentation","title":"Writing Documentation","text":""},{"location":"v1/services/mkdocs/#markdown-basics","title":"Markdown Basics","text":""},{"location":"v1/services/mkdocs/#example-page","title":"Example Page","text":"
# Page Title\n\nThis is a sample documentation page.\n\n## Section\n\nContent goes here with **bold** and *italic* text.\n\n### Code Example\n\n```python\ndef hello_world():\n    print(\"Hello, World!\")\n

Note

This is an informational note.

## Building and Deployment\n\n### Development\n\nThe development server runs automatically with live reload.\n\n### Building Static Site\n\n```bash\ndocker exec mkdocs-changemaker mkdocs build\n

The built site will be available in the mkdocs/site/ directory.

"},{"location":"v1/services/mkdocs/#customization","title":"Customization","text":""},{"location":"v1/services/mkdocs/#themes-and-colors","title":"Themes and Colors","text":"

Customize appearance in mkdocs.yml:

theme:\n  name: material\n  palette:\n    primary: blue\n    accent: indigo\n
"},{"location":"v1/services/mkdocs/#custom-css","title":"Custom CSS","text":"

Add custom styles in docs/stylesheets/extra.css.

"},{"location":"v1/services/mkdocs/#official-documentation","title":"Official Documentation","text":"

For comprehensive MkDocs Material documentation: - MkDocs Material - MkDocs Documentation - Markdown Guide

"},{"location":"v1/services/n8n/","title":"n8n","text":"

Workflow automation tool for connecting services and automating tasks.

"},{"location":"v1/services/n8n/#overview","title":"Overview","text":"

n8n is a powerful workflow automation tool that allows you to connect various apps and services together. It provides a visual interface for creating automated workflows, making it easy to integrate different systems and automate repetitive tasks.

"},{"location":"v1/services/n8n/#features","title":"Features","text":""},{"location":"v1/services/n8n/#access","title":"Access","text":""},{"location":"v1/services/n8n/#configuration","title":"Configuration","text":""},{"location":"v1/services/n8n/#environment-variables","title":"Environment Variables","text":""},{"location":"v1/services/n8n/#volumes","title":"Volumes","text":""},{"location":"v1/services/n8n/#getting-started","title":"Getting Started","text":"
  1. Access n8n at http://localhost:5678
  2. Log in with your admin credentials
  3. Create your first workflow
  4. Add nodes for different services
  5. Configure connections between nodes
  6. Test and activate your workflow
"},{"location":"v1/services/n8n/#common-use-cases","title":"Common Use Cases","text":""},{"location":"v1/services/n8n/#documentation-automation","title":"Documentation Automation","text":""},{"location":"v1/services/n8n/#email-campaign-integration","title":"Email Campaign Integration","text":""},{"location":"v1/services/n8n/#database-management-with-nocodb","title":"Database Management with NocoDB","text":""},{"location":"v1/services/n8n/#development-workflows","title":"Development Workflows","text":""},{"location":"v1/services/n8n/#data-processing","title":"Data Processing","text":""},{"location":"v1/services/n8n/#example-workflows","title":"Example Workflows","text":""},{"location":"v1/services/n8n/#simple-webhook-to-email","title":"Simple Webhook to Email","text":"
Webhook \u2192 Email\n
"},{"location":"v1/services/n8n/#scheduled-documentation-backup","title":"Scheduled Documentation Backup","text":"
Schedule \u2192 Read Files \u2192 Compress \u2192 Upload to Storage\n
"},{"location":"v1/services/n8n/#git-integration","title":"Git Integration","text":"
Git Webhook \u2192 Process Changes \u2192 Update Documentation \u2192 Notify Team\n
"},{"location":"v1/services/n8n/#security-considerations","title":"Security Considerations","text":""},{"location":"v1/services/n8n/#integration-with-other-services","title":"Integration with Other Services","text":"

n8n can integrate with all services in your Changemaker Lite setup:

"},{"location":"v1/services/n8n/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/services/n8n/#common-issues","title":"Common Issues","text":""},{"location":"v1/services/n8n/#debugging","title":"Debugging","text":"
# Check container logs\ndocker logs n8n-changemaker\n\n# Access container shell\ndocker exec -it n8n-changemaker sh\n\n# Check workflow executions in the UI\n# Visit http://localhost:5678 \u2192 Executions\n
"},{"location":"v1/services/n8n/#official-documentation","title":"Official Documentation","text":"

For comprehensive n8n documentation:

"},{"location":"v1/services/nocodb/","title":"NocoDB","text":"

No-code database platform that turns any database into a smart spreadsheet.

"},{"location":"v1/services/nocodb/#overview","title":"Overview","text":"

NocoDB is an open-source no-code platform that transforms any database into a smart spreadsheet interface. It provides a user-friendly way to manage data, create forms, build APIs, and collaborate on database operations without requiring extensive technical knowledge.

"},{"location":"v1/services/nocodb/#features","title":"Features","text":""},{"location":"v1/services/nocodb/#access","title":"Access","text":""},{"location":"v1/services/nocodb/#configuration","title":"Configuration","text":""},{"location":"v1/services/nocodb/#environment-variables","title":"Environment Variables","text":""},{"location":"v1/services/nocodb/#database-backend","title":"Database Backend","text":"

NocoDB uses a dedicated PostgreSQL instance (root_db) with the following configuration:

"},{"location":"v1/services/nocodb/#volumes","title":"Volumes","text":""},{"location":"v1/services/nocodb/#getting-started","title":"Getting Started","text":"
  1. Access NocoDB: Navigate to http://localhost:8090
  2. Initial Setup: Complete the onboarding process
  3. Create Project: Start with a new project or connect existing databases
  4. Add Tables: Import data or create new tables
  5. Configure Views: Set up different views (Grid, Form, Gallery, etc.)
  6. Set Permissions: Configure user access and sharing settings
"},{"location":"v1/services/nocodb/#common-use-cases","title":"Common Use Cases","text":""},{"location":"v1/services/nocodb/#content-management","title":"Content Management","text":""},{"location":"v1/services/nocodb/#project-management","title":"Project Management","text":""},{"location":"v1/services/nocodb/#data-collection","title":"Data Collection","text":""},{"location":"v1/services/nocodb/#integration-with-other-services","title":"Integration with Other Services","text":"

NocoDB can integrate well with other Changemaker Lite services:

"},{"location":"v1/services/nocodb/#api-usage","title":"API Usage","text":"

NocoDB automatically generates REST APIs for all your tables:

# Get all records from a table\nGET http://localhost:8090/api/v1/db/data/v1/{project}/table/{table}\n\n# Create a new record\nPOST http://localhost:8090/api/v1/db/data/v1/{project}/table/{table}\n\n# Update a record\nPATCH http://localhost:8090/api/v1/db/data/v1/{project}/table/{table}/{id}\n
"},{"location":"v1/services/nocodb/#backup-and-data-management","title":"Backup and Data Management","text":""},{"location":"v1/services/nocodb/#database-backup","title":"Database Backup","text":"

Since NocoDB uses PostgreSQL, you can backup the database:

# Backup NocoDB database\ndocker exec root_db pg_dump -U postgres root_db > nocodb_backup.sql\n\n# Restore from backup\ndocker exec -i root_db psql -U postgres root_db < nocodb_backup.sql\n
"},{"location":"v1/services/nocodb/#application-data","title":"Application Data","text":"

Application settings and metadata are stored in the nc_data volume.

"},{"location":"v1/services/nocodb/#security-considerations","title":"Security Considerations","text":""},{"location":"v1/services/nocodb/#performance-tips","title":"Performance Tips","text":""},{"location":"v1/services/nocodb/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/services/nocodb/#common-issues","title":"Common Issues","text":"

Service won't start: Check if the PostgreSQL database is healthy

docker logs root_db\n

Database connection errors: Verify database credentials and network connectivity

docker exec nocodb nc_data nc\n

Performance issues: Monitor resource usage and optimize queries

docker stats nocodb root_db\n
"},{"location":"v1/services/nocodb/#official-documentation","title":"Official Documentation","text":"

For comprehensive guides and advanced features:

"},{"location":"v1/services/postgresql/","title":"PostgreSQL Database","text":"

Reliable database backend for applications.

"},{"location":"v1/services/postgresql/#overview","title":"Overview","text":"

PostgreSQL is a powerful, open-source relational database system. In Changemaker Lite, it serves as the backend database for Listmonk and can be used by other applications requiring persistent data storage.

"},{"location":"v1/services/postgresql/#features","title":"Features","text":""},{"location":"v1/services/postgresql/#access","title":"Access","text":""},{"location":"v1/services/postgresql/#configuration","title":"Configuration","text":""},{"location":"v1/services/postgresql/#environment-variables","title":"Environment Variables","text":""},{"location":"v1/services/postgresql/#health-checks","title":"Health Checks","text":"

The PostgreSQL container includes health checks to ensure the database is ready before dependent services start.

"},{"location":"v1/services/postgresql/#data-persistence","title":"Data Persistence","text":"

Database data is stored in a Docker volume (listmonk-data) to ensure persistence across container restarts.

"},{"location":"v1/services/postgresql/#connecting-to-the-database","title":"Connecting to the Database","text":""},{"location":"v1/services/postgresql/#from-host-machine","title":"From Host Machine","text":"

You can connect to PostgreSQL from your host machine using:

psql -h localhost -p 5432 -U [username] -d [database]\n
"},{"location":"v1/services/postgresql/#from-other-containers","title":"From Other Containers","text":"

Other containers can connect using the internal hostname listmonk-db on port 5432.

"},{"location":"v1/services/postgresql/#backup-and-restore","title":"Backup and Restore","text":""},{"location":"v1/services/postgresql/#backup","title":"Backup","text":"
docker exec listmonk-db pg_dump -U [username] [database] > backup.sql\n
"},{"location":"v1/services/postgresql/#restore","title":"Restore","text":"
docker exec -i listmonk-db psql -U [username] [database] < backup.sql\n
"},{"location":"v1/services/postgresql/#monitoring","title":"Monitoring","text":"

Monitor database health and performance through: - Container logs: docker logs listmonk-db - Database metrics and queries - Connection monitoring

"},{"location":"v1/services/postgresql/#security-considerations","title":"Security Considerations","text":""},{"location":"v1/services/postgresql/#official-documentation","title":"Official Documentation","text":"

For comprehensive PostgreSQL documentation: - PostgreSQL Documentation - Docker PostgreSQL Image

"},{"location":"v1/services/static-server/","title":"Static Site Server","text":"

Nginx-powered static site server for hosting built documentation and websites.

"},{"location":"v1/services/static-server/#overview","title":"Overview","text":"

The Static Site Server uses Nginx to serve your built documentation and static websites. It's configured to serve the built MkDocs site and other static content with high performance and reliability.

"},{"location":"v1/services/static-server/#features","title":"Features","text":""},{"location":"v1/services/static-server/#access","title":"Access","text":""},{"location":"v1/services/static-server/#configuration","title":"Configuration","text":""},{"location":"v1/services/static-server/#environment-variables","title":"Environment Variables","text":""},{"location":"v1/services/static-server/#volumes","title":"Volumes","text":""},{"location":"v1/services/static-server/#usage","title":"Usage","text":"
  1. Build your MkDocs site: docker exec mkdocs-changemaker mkdocs build
  2. The built site is automatically available at http://localhost:4001
  3. Any files in ./mkdocs/site/ will be served statically
"},{"location":"v1/services/static-server/#file-structure","title":"File Structure","text":"
mkdocs/site/           # Served at /\n\u251c\u2500\u2500 index.html         # Homepage\n\u251c\u2500\u2500 assets/           # CSS, JS, images\n\u251c\u2500\u2500 services/         # Service documentation\n\u2514\u2500\u2500 search/           # Search functionality\n
"},{"location":"v1/services/static-server/#performance-features","title":"Performance Features","text":""},{"location":"v1/services/static-server/#custom-configuration","title":"Custom Configuration","text":"

For advanced Nginx configuration, you can: 1. Create custom Nginx config files 2. Mount them as volumes 3. Restart the container

"},{"location":"v1/services/static-server/#monitoring","title":"Monitoring","text":"

Monitor the static site server through: - Container logs: docker logs mkdocs-site-server-changemaker - Access logs for traffic analysis - Performance metrics

"},{"location":"v1/services/static-server/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/services/static-server/#common-issues","title":"Common Issues","text":""},{"location":"v1/services/static-server/#debugging","title":"Debugging","text":"
# Check container logs\ndocker logs mkdocs-site-server-changemaker\n\n# Verify files are present\ndocker exec mkdocs-site-server-changemaker ls -la /config/www\n\n# Test file serving\ncurl -I http://localhost:4001\n
"},{"location":"v1/services/static-server/#official-documentation","title":"Official Documentation","text":"

For more information about the underlying Nginx server: - LinuxServer.io Nginx - Nginx Documentation

"},{"location":"v2/","title":"Changemaker Lite V2 Documentation","text":"

V2 is Production Ready

Changemaker Lite V2 is a complete architectural rebuild now running in production. This documentation covers the modern TypeScript stack with dual API architecture, React admin interface, and comprehensive feature modules.

"},{"location":"v2/#overview","title":"Overview","text":"

Changemaker Lite V2 is a self-hosted political campaign platform that consolidates advocacy email campaigns, geographic mapping, volunteer management, and administration into a single unified TypeScript stack.

"},{"location":"v2/#key-highlights","title":"Key Highlights","text":""},{"location":"v2/#architecture-diagram","title":"Architecture Diagram","text":"
graph TB\n    User[User Browser]\n    Nginx[Nginx Reverse Proxy]\n    Admin[React Admin GUI<br/>port 3000]\n    ExpressAPI[Express API<br/>port 4000<br/>Prisma ORM]\n    FastifyAPI[Fastify Media API<br/>port 4100<br/>Drizzle ORM]\n    Postgres[(PostgreSQL 16)]\n    Redis[(Redis)]\n    BullMQ[BullMQ Queues]\n    Listmonk[Listmonk<br/>Newsletter]\n    Prometheus[Prometheus<br/>Monitoring]\n\n    User --> Nginx\n    Nginx --> |app.cmlite.org| Admin\n    Nginx --> |api.cmlite.org| ExpressAPI\n    Nginx --> |media.cmlite.org| FastifyAPI\n\n    Admin --> ExpressAPI\n    Admin --> FastifyAPI\n\n    ExpressAPI --> Postgres\n    ExpressAPI --> Redis\n    ExpressAPI --> BullMQ\n\n    FastifyAPI --> Postgres\n    FastifyAPI --> Redis\n\n    BullMQ --> Redis\n    ExpressAPI --> Listmonk\n    ExpressAPI --> Prometheus\n    FastifyAPI --> Prometheus
"},{"location":"v2/#feature-modules","title":"Feature Modules","text":""},{"location":"v2/#influence-module","title":"Influence Module","text":"

Email advocacy campaigns targeting elected representatives with:

Learn more \u2192

"},{"location":"v2/#map-module","title":"Map Module","text":"

Geographic mapping and volunteer canvassing with:

Learn more \u2192

"},{"location":"v2/#landing-pages","title":"Landing Pages","text":"

GrapesJS-based page builder with:

Learn more \u2192

"},{"location":"v2/#email-templates","title":"Email Templates","text":"

Template management system with:

Learn more \u2192

"},{"location":"v2/#media-manager","title":"Media Manager","text":"

Video library management with:

Learn more \u2192

"},{"location":"v2/#newsletter-integration","title":"Newsletter Integration","text":"

Listmonk sync with:

Learn more \u2192

"},{"location":"v2/#observability","title":"Observability","text":"

Comprehensive monitoring with:

Learn more \u2192

"},{"location":"v2/#quick-links","title":"Quick Links","text":""},{"location":"v2/#getting-started","title":"Getting Started","text":""},{"location":"v2/#architecture","title":"Architecture","text":""},{"location":"v2/#development","title":"Development","text":""},{"location":"v2/#deployment","title":"Deployment","text":""},{"location":"v2/#api-reference","title":"API Reference","text":""},{"location":"v2/#user-guides","title":"User Guides","text":""},{"location":"v2/#technology-stack","title":"Technology Stack","text":""},{"location":"v2/#backend","title":"Backend","text":""},{"location":"v2/#frontend","title":"Frontend","text":""},{"location":"v2/#infrastructure","title":"Infrastructure","text":""},{"location":"v2/#project-status","title":"Project Status","text":""},{"location":"v2/#completed-phases-1-14","title":"Completed Phases (1-14)","text":"

\u2705 Phase 1: Foundation - Database, auth, basic API \u2705 Phase 2: Auth + User Management - JWT, RBAC, user CRUD \u2705 Phase 3: Admin GUI Foundation - React admin, routing, layouts \u2705 Phase 4: Influence (Campaigns) - Campaign CRUD, admin pages \u2705 Phase 5: Representatives + Postal Codes - API integration, caching \u2705 Phase 6: Email Sending - BullMQ queue, SMTP, tracking \u2705 Phase 7: Response Wall + Public Campaign View - Public pages, moderation \u2705 Phase 8: Map (Locations) - Geocoding, CSV import, map rendering \u2705 Phase 9: Map (Shifts) - Shift management, public signup \u2705 Phase 10: Walk Sheets & QR Codes - Printable forms, QR generation \u2705 Phase 11: Newsletter Integration - Listmonk sync \u2705 Phase 12: Landing Page Builder - GrapesJS editor, MkDocs export \u2705 Phase 13: Volunteer Canvassing - GPS tracking, visit recording \u2705 Phase 14: Monitoring + DevOps - Prometheus, Grafana, backup

"},{"location":"v2/#additional-features","title":"Additional Features","text":"

\u2705 Security Audit - 13 findings addressed (Feb 2026) \u2705 NAR 2025 Import - Canadian electoral data support \u2705 Media Manager - Dual API video library \u2705 Email Templates - Template management system \u2705 Data Quality Dashboard - Geocoding metrics \u2705 Observability Dashboard - Monitoring integration

"},{"location":"v2/#current-phase","title":"Current Phase","text":"

\ud83d\udea7 Phase 15: Testing + Polish - Comprehensive testing, documentation

"},{"location":"v2/#migration-from-v1","title":"Migration from V1","text":"

If you're migrating from Changemaker Lite V1 (NocoDB-based architecture), see the Migration Guide for:

"},{"location":"v2/#contributing","title":"Contributing","text":"

Changemaker Lite is open source. We welcome contributions! See the Contributing Guide for:

"},{"location":"v2/#support","title":"Support","text":"

Last Updated: February 2026 | Version: 2.0.0 | Status: Production Ready

"},{"location":"v2/api-reference/","title":"API Reference","text":"

Complete REST API reference for Changemaker Lite V2. This section documents all API endpoints, request/response formats, authentication, and error handling.

"},{"location":"v2/api-reference/#overview","title":"Overview","text":"

Changemaker Lite V2 provides two REST APIs:

Both APIs use JSON for request/response bodies and follow RESTful conventions.

"},{"location":"v2/api-reference/#api-documentation","title":"API Documentation","text":"

API reference documentation will be added as the API stabilizes. Planned documentation includes:

"},{"location":"v2/api-reference/#authentication-endpoints","title":"Authentication Endpoints","text":""},{"location":"v2/api-reference/#user-endpoints","title":"User Endpoints","text":""},{"location":"v2/api-reference/#campaign-endpoints","title":"Campaign Endpoints","text":""},{"location":"v2/api-reference/#location-endpoints","title":"Location Endpoints","text":""},{"location":"v2/api-reference/#map-endpoints","title":"Map Endpoints","text":""},{"location":"v2/api-reference/#content-endpoints","title":"Content Endpoints","text":""},{"location":"v2/api-reference/#media-endpoints-port-4100","title":"Media Endpoints (Port 4100)","text":""},{"location":"v2/api-reference/#authentication","title":"Authentication","text":"

All authenticated endpoints require a valid JWT access token in the Authorization header:

Authorization: Bearer <access_token>\n
"},{"location":"v2/api-reference/#token-lifecycle","title":"Token Lifecycle","text":"
  1. Login - POST /api/auth/login
  2. Returns: accessToken (15min) + refreshToken (7 days)

  3. Access Protected Resource - Include token in header

  4. Token verified by authenticate middleware

  5. Refresh Token - POST /api/auth/refresh

  6. Provide: refreshToken
  7. Returns: New accessToken + refreshToken

  8. Logout - POST /api/auth/logout

  9. Invalidates refresh token
"},{"location":"v2/api-reference/#role-based-access","title":"Role-Based Access","text":"

Endpoints are protected by role requirements:

"},{"location":"v2/api-reference/#request-format","title":"Request Format","text":""},{"location":"v2/api-reference/#json-body","title":"JSON Body","text":"
POST /api/campaigns\nContent-Type: application/json\nAuthorization: Bearer <token>\n\n{\n  \"name\": \"Save the Parks\",\n  \"description\": \"Campaign description\",\n  \"published\": true\n}\n
"},{"location":"v2/api-reference/#query-parameters","title":"Query Parameters","text":"
GET /api/campaigns?page=1&limit=20&search=parks\n
"},{"location":"v2/api-reference/#path-parameters","title":"Path Parameters","text":"
GET /api/campaigns/:id\n
"},{"location":"v2/api-reference/#response-format","title":"Response Format","text":""},{"location":"v2/api-reference/#success-response","title":"Success Response","text":"
{\n  \"id\": 1,\n  \"name\": \"Save the Parks\",\n  \"description\": \"Campaign description\",\n  \"published\": true,\n  \"createdAt\": \"2026-01-01T00:00:00.000Z\",\n  \"updatedAt\": \"2026-01-01T00:00:00.000Z\"\n}\n
"},{"location":"v2/api-reference/#paginated-response","title":"Paginated Response","text":"
{\n  \"data\": [...],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 100,\n    \"totalPages\": 5\n  }\n}\n
"},{"location":"v2/api-reference/#error-response","title":"Error Response","text":"
{\n  \"error\": \"Validation error\",\n  \"details\": \"Invalid email format\",\n  \"statusCode\": 400\n}\n
"},{"location":"v2/api-reference/#status-codes","title":"Status Codes","text":""},{"location":"v2/api-reference/#rate-limiting","title":"Rate Limiting","text":"

Rate limits vary by endpoint:

Rate limit headers:

X-RateLimit-Limit: 60\nX-RateLimit-Remaining: 59\nX-RateLimit-Reset: 1640995200\n
"},{"location":"v2/api-reference/#cors","title":"CORS","text":"

CORS is enabled for all origins in development:

app.use(cors({\n  origin: '*',\n  credentials: true,\n}));\n

Production should restrict to known domains.

"},{"location":"v2/api-reference/#validation","title":"Validation","text":"

Request bodies are validated using Zod schemas. Validation errors return 400 with details:

{\n  \"error\": \"Validation error\",\n  \"details\": {\n    \"email\": \"Invalid email format\",\n    \"password\": \"Password must be at least 12 characters\"\n  },\n  \"statusCode\": 400\n}\n
"},{"location":"v2/api-reference/#pagination","title":"Pagination","text":"

List endpoints support pagination:

Example:

GET /api/campaigns?page=2&limit=50\n

"},{"location":"v2/api-reference/#search-filtering","title":"Search & Filtering","text":"

List endpoints support search and filtering:

Example:

GET /api/campaigns?search=parks&published=true\n

"},{"location":"v2/api-reference/#sorting","title":"Sorting","text":"

List endpoints support sorting:

Example:

GET /api/campaigns?sort=createdAt&order=desc\n

"},{"location":"v2/api-reference/#api-endpoints-by-module","title":"API Endpoints by Module","text":""},{"location":"v2/api-reference/#authentication_1","title":"Authentication","text":""},{"location":"v2/api-reference/#users","title":"Users","text":""},{"location":"v2/api-reference/#settings","title":"Settings","text":""},{"location":"v2/api-reference/#campaigns","title":"Campaigns","text":""},{"location":"v2/api-reference/#representatives","title":"Representatives","text":""},{"location":"v2/api-reference/#responses","title":"Responses","text":""},{"location":"v2/api-reference/#postal-codes","title":"Postal Codes","text":""},{"location":"v2/api-reference/#campaign-emails","title":"Campaign Emails","text":""},{"location":"v2/api-reference/#email-queue","title":"Email Queue","text":""},{"location":"v2/api-reference/#locations","title":"Locations","text":""},{"location":"v2/api-reference/#cuts","title":"Cuts","text":""},{"location":"v2/api-reference/#shifts","title":"Shifts","text":""},{"location":"v2/api-reference/#canvass","title":"Canvass","text":""},{"location":"v2/api-reference/#tracking","title":"Tracking","text":""},{"location":"v2/api-reference/#map-settings","title":"Map Settings","text":""},{"location":"v2/api-reference/#pages","title":"Pages","text":""},{"location":"v2/api-reference/#email-templates","title":"Email Templates","text":""},{"location":"v2/api-reference/#media-port-4100","title":"Media (Port 4100)","text":""},{"location":"v2/api-reference/#listmonk","title":"Listmonk","text":""},{"location":"v2/api-reference/#pangolin","title":"Pangolin","text":""},{"location":"v2/api-reference/#docs","title":"Docs","text":""},{"location":"v2/api-reference/#qr","title":"QR","text":""},{"location":"v2/api-reference/#observability","title":"Observability","text":""},{"location":"v2/api-reference/#services","title":"Services","text":""},{"location":"v2/api-reference/#openapi-specification","title":"OpenAPI Specification","text":"

OpenAPI/Swagger documentation is planned for future releases. This will provide:

"},{"location":"v2/api-reference/#testing","title":"Testing","text":"

Test API endpoints using:

Example with curl:

# Login\ncurl -X POST http://localhost:4000/api/auth/login \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\":\"admin@example.com\",\"password\":\"Admin123!\"}'\n\n# Get campaigns (with token)\ncurl http://localhost:4000/api/campaigns \\\n  -H \"Authorization: Bearer <token>\"\n
"},{"location":"v2/api-reference/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/architecture/","title":"V2 Architecture Overview","text":"

Changemaker Lite V2 is built on a modern microservices architecture with a dual API design, React admin interface, and comprehensive observability.

"},{"location":"v2/architecture/#system-architecture","title":"System Architecture","text":"
graph TB\n    subgraph \"User Access\"\n        Browser[Web Browser]\n        VolunteerApp[Volunteer Mobile]\n    end\n\n    subgraph \"Nginx Reverse Proxy\"\n        Nginx[Nginx<br/>Subdomain Router]\n    end\n\n    subgraph \"Frontend Layer\"\n        AdminGUI[Admin GUI<br/>React + Vite + Ant Design<br/>Port 3000]\n        PublicPages[Public Pages<br/>Dark Theme]\n        VolunteerPortal[Volunteer Portal<br/>GPS Canvassing]\n    end\n\n    subgraph \"Backend Layer - Dual API\"\n        ExpressAPI[Express API<br/>Main Features<br/>Port 4000<br/>Prisma ORM]\n        FastifyAPI[Fastify API<br/>Media Library<br/>Port 4100<br/>Drizzle ORM]\n    end\n\n    subgraph \"Data Layer\"\n        Postgres[(PostgreSQL 16<br/>27+ Models)]\n        Redis[(Redis<br/>Cache + Queues)]\n    end\n\n    subgraph \"Job Processing\"\n        EmailQueue[BullMQ<br/>Email Queue]\n        GeocodeQueue[BullMQ<br/>Geocoding Queue]\n    end\n\n    subgraph \"External Services\"\n        SMTP[SMTP Server<br/>Email Delivery]\n        Represent[Represent API<br/>Canadian Reps]\n        Geocoding[Geocoding Providers<br/>6 Services]\n        Listmonk[Listmonk<br/>Newsletter Platform]\n    end\n\n    subgraph \"Observability\"\n        Prometheus[Prometheus<br/>Metrics]\n        Grafana[Grafana<br/>Dashboards]\n        Alertmanager[Alertmanager<br/>Notifications]\n    end\n\n    Browser --> Nginx\n    VolunteerApp --> Nginx\n\n    Nginx --> AdminGUI\n    Nginx --> PublicPages\n    Nginx --> VolunteerPortal\n\n    AdminGUI --> ExpressAPI\n    AdminGUI --> FastifyAPI\n    PublicPages --> ExpressAPI\n    VolunteerPortal --> ExpressAPI\n\n    ExpressAPI --> Postgres\n    ExpressAPI --> Redis\n    ExpressAPI --> EmailQueue\n    ExpressAPI --> GeocodeQueue\n    ExpressAPI --> Represent\n    ExpressAPI --> Geocoding\n    ExpressAPI --> Listmonk\n    ExpressAPI --> Prometheus\n\n    FastifyAPI --> Postgres\n    FastifyAPI --> Redis\n    FastifyAPI --> Prometheus\n\n    EmailQueue --> Redis\n    EmailQueue --> SMTP\n    GeocodeQueue --> Redis\n    GeocodeQueue --> Geocoding\n\n    Prometheus --> Grafana\n    Prometheus --> Alertmanager
"},{"location":"v2/architecture/#core-components","title":"Core Components","text":""},{"location":"v2/architecture/#1-nginx-reverse-proxy","title":"1. Nginx Reverse Proxy","text":"

Purpose: Routes HTTP requests to appropriate services based on subdomain

Subdomains: - app.cmlite.org \u2192 Admin GUI (React) - api.cmlite.org \u2192 Express API (main features) - media.cmlite.org \u2192 Fastify API (video library) - db.cmlite.org \u2192 NocoDB (data browser) - docs.cmlite.org \u2192 MkDocs (documentation) - listmonk.cmlite.org \u2192 Listmonk (newsletter) - grafana.cmlite.org \u2192 Grafana (monitoring) - And 8 more service subdomains...

Configuration: /nginx/conf.d/

Learn more \u2192

"},{"location":"v2/architecture/#2-frontend-layer","title":"2. Frontend Layer","text":""},{"location":"v2/architecture/#admin-gui-port-3000","title":"Admin GUI (Port 3000)","text":"

Structure: - 32 admin pages (campaigns, locations, users, settings, etc.) - 6 public pages (campaign view, response wall, map, shifts) - 4 volunteer portal pages (canvassing, assignments, activity) - Shared components (map, canvass, GrapesJS editor)

Learn more \u2192

"},{"location":"v2/architecture/#public-pages","title":"Public Pages","text":""},{"location":"v2/architecture/#volunteer-portal","title":"Volunteer Portal","text":""},{"location":"v2/architecture/#3-backend-layer-dual-api-design","title":"3. Backend Layer - Dual API Design","text":""},{"location":"v2/architecture/#express-api-port-4000","title":"Express API (Port 4000)","text":"

Main application server handling core features:

14 Feature Modules: 1. auth - JWT login, register, refresh, logout 2. users - User CRUD with pagination 3. settings - Site settings singleton 4. campaigns - Campaign CRUD + public routes 5. representatives - Represent API integration 6. responses - Response wall + moderation 7. email-queue - BullMQ queue admin 8. campaign-emails - Email tracking + stats 9. postal-codes - Postal code cache 10. locations - Location CRUD + geocoding + NAR import 11. cuts - Cut (polygon) CRUD + spatial queries 12. shifts - Shift CRUD + signups 13. canvass - Volunteer canvassing (sessions, visits, routes) 14. pages - Landing page builder (GrapesJS)

Plus: email-templates, listmonk, pangolin, docs, qr, services, observability

ORM: Prisma (27+ models)

Architecture: - Layered structure (routes \u2192 services \u2192 database) - Zod schema validation - Role-based access control (RBAC) - Error handling middleware - Winston logging

Learn more \u2192

"},{"location":"v2/architecture/#fastify-api-port-4100","title":"Fastify API (Port 4100)","text":"

Specialized microservice for media library:

Features: - Video CRUD (title, duration, orientation, producer) - Shared media (public gallery categories) - Lock/unlock system (public visibility control) - Reaction system (6 standard emojis) - Job queue monitoring - Bulk operations

ORM: Drizzle (lightweight schema-first)

Why Separate?: - Performance isolation (video ops don't slow main API) - Different ORM evaluation (Drizzle vs Prisma) - Independent scaling - Clear service boundaries

Shared Resources: - Same PostgreSQL database (different schemas) - Same Redis instance - Reuses JWT_ACCESS_SECRET for auth

Learn more \u2192

"},{"location":"v2/architecture/#4-data-layer","title":"4. Data Layer","text":""},{"location":"v2/architecture/#postgresql-16","title":"PostgreSQL 16","text":"

Primary database with two ORM schemas:

Prisma Schema (27+ models): - User, RefreshToken (auth) - Campaign, Representative, Response, CampaignEmail (influence) - Location, Cut, Shift, ShiftSignup (map) - CanvassSession, CanvassVisit, TrackingSession, TrackPoint (canvass) - LandingPage, PageBlock, EmailTemplate (content) - SiteSettings, MapSettings (config)

Drizzle Schema (media tables): - videos - shared_media - reactions - jobs

Indexes: Optimized for common queries (userId, campaignId, cutId, etc.)

Learn more \u2192

"},{"location":"v2/architecture/#redis","title":"Redis","text":"

Multi-purpose cache and queue backend:

Authentication: Required (REDIS_PASSWORD env var)

"},{"location":"v2/architecture/#5-job-processing","title":"5. Job Processing","text":""},{"location":"v2/architecture/#bullmq-queues","title":"BullMQ Queues","text":"

Async job processing for long-running operations:

Email Queue: - Campaign email sending (SMTP) - Email verification (double opt-in) - Confirmation emails (shift signups) - Retry logic (exponential backoff) - Rate limiting (avoid spam flagging)

Geocoding Queue: - Bulk address geocoding - Multi-provider fallback (6 services) - Rate limit compliance (500 jobs/min) - Result caching

Queue Management: - Admin routes for pause/resume - Job status monitoring - Failed job inspection - Queue metrics (Prometheus)

"},{"location":"v2/architecture/#6-external-services","title":"6. External Services","text":""},{"location":"v2/architecture/#smtp-server","title":"SMTP Server","text":"

Email delivery for: - Campaign advocacy emails - Email verification - Password reset - Shift confirmation - Admin notifications

Dev Mode: MailHog captures emails (EMAIL_TEST_MODE=true)

"},{"location":"v2/architecture/#represent-api","title":"Represent API","text":"

Canadian elected representative lookup: - Postal code \u2192 MPs, MPPs, councillors - Caching (7-day TTL per postal code) - Fallback to cached data on API errors

"},{"location":"v2/architecture/#geocoding-providers","title":"Geocoding Providers","text":"

Multi-provider geocoding with fallback:

  1. Nominatim (OpenStreetMap, free)
  2. Mapbox (requires API key, best accuracy)
  3. ArcGIS (free tier available)
  4. Photon (OSM-based, no key required)
  5. Google (requires API key, high cost)
  6. LocationIQ (requires API key, generous free tier)

Strategy: Try each provider in order until success

"},{"location":"v2/architecture/#listmonk-newsletter-platform","title":"Listmonk Newsletter Platform","text":"

Email marketing integration: - Sync participants/locations/users \u2192 subscriber lists - Newsletter campaigns (separate from advocacy emails) - API integration (basic auth) - Health monitoring

"},{"location":"v2/architecture/#7-observability-stack","title":"7. Observability Stack","text":""},{"location":"v2/architecture/#prometheus","title":"Prometheus","text":"

Metrics collection with custom instrumentation:

12 Custom Metrics (cm_* prefix): - cm_api_uptime_seconds - API availability - cm_email_queue_size - Queue depth - cm_email_sent_total - Email delivery count - cm_geocode_success_rate - Geocoding quality - cm_active_canvass_sessions - Live canvassing - And 7 more domain-specific metrics...

HTTP Metrics: - http_request_total - Total requests - http_request_duration_seconds - Latency histogram - http_request_errors_total - Error count

Scrape Targets: - Express API (:4000/metrics) - Fastify API (:4100/metrics) - Redis Exporter - Node Exporter (host metrics) - cAdvisor (container metrics)

Learn more \u2192

"},{"location":"v2/architecture/#grafana","title":"Grafana","text":"

Visualization dashboards:

  1. Application Overview - API metrics, queue stats, sessions
  2. Infrastructure - Container metrics, host resources, Redis
  3. Alerts & SLOs - Error budgets, SLI tracking

Auto-provisioned: Dashboards in /configs/grafana/

"},{"location":"v2/architecture/#alertmanager","title":"Alertmanager","text":"

Alert routing and notifications:

12 Alert Rules: - High error rate (>5% for 5 minutes) - Email queue stuck (no jobs processed in 10 minutes) - Service down (health check fails) - Database connection pool exhausted - Redis unavailable - And 7 more critical conditions...

Notification Channels: - Gotify (self-hosted push notifications) - Email (SMTP) - Webhook (custom integrations)

"},{"location":"v2/architecture/#request-lifecycle","title":"Request Lifecycle","text":""},{"location":"v2/architecture/#example-public-campaign-email-submission","title":"Example: Public Campaign Email Submission","text":"
sequenceDiagram\n    participant User as User Browser\n    participant Nginx\n    participant Admin as Admin GUI\n    participant Express as Express API\n    participant DB as PostgreSQL\n    participant Redis\n    participant Queue as BullMQ\n    participant SMTP as SMTP Server\n    participant Rep as Represent API\n\n    User->>Nginx: Visit /campaigns/123\n    Nginx->>Admin: Route to React app\n    Admin->>Express: GET /api/campaigns/123 (public)\n    Express->>DB: Query campaign\n    DB-->>Express: Campaign data\n    Express-->>Admin: Campaign JSON\n    Admin-->>User: Render campaign page\n\n    User->>Admin: Enter postal code + submit\n    Admin->>Express: POST /api/postal-codes (lookup)\n    Express->>Redis: Check cache\n    Redis-->>Express: Cache miss\n    Express->>Rep: GET /representatives/postal-code\n    Rep-->>Express: Representative list\n    Express->>Redis: Cache for 7 days\n    Express-->>Admin: Representatives JSON\n    Admin-->>User: Show rep selection\n\n    User->>Admin: Select rep + write email + submit\n    Admin->>Express: POST /api/responses (create)\n    Express->>DB: Insert response\n    Express->>Queue: Enqueue verification email\n    Express->>DB: Insert campaign email record\n    DB-->>Express: Response created\n    Express-->>Admin: Success response\n    Admin-->>User: Show success message\n\n    Queue->>SMTP: Send verification email\n    SMTP-->>Queue: Delivery confirmed\n\n    User->>User: Click verification link (email)\n    User->>Nginx: GET /verify-response/:token\n    Nginx->>Admin: Route to React app\n    Admin->>Express: POST /api/responses/:id/verify\n    Express->>DB: Update response (verified=true)\n    Express->>Queue: Enqueue campaign email to rep\n    DB-->>Express: Response verified\n    Express-->>Admin: Success\n    Admin-->>User: Email sent confirmation\n\n    Queue->>SMTP: Send campaign email to rep\n    SMTP-->>Queue: Delivery confirmed
"},{"location":"v2/architecture/#technology-decisions","title":"Technology Decisions","text":""},{"location":"v2/architecture/#why-typescript","title":"Why TypeScript?","text":""},{"location":"v2/architecture/#why-prisma-drizzle","title":"Why Prisma + Drizzle?","text":""},{"location":"v2/architecture/#why-dual-api","title":"Why Dual API?","text":""},{"location":"v2/architecture/#why-jwt-over-sessions","title":"Why JWT over Sessions?","text":""},{"location":"v2/architecture/#why-bullmq-over-bull","title":"Why BullMQ over Bull?","text":""},{"location":"v2/architecture/#why-postgresql-over-nosql","title":"Why PostgreSQL over NoSQL?","text":""},{"location":"v2/architecture/#deployment-architecture","title":"Deployment Architecture","text":""},{"location":"v2/architecture/#docker-compose","title":"Docker Compose","text":"

All services orchestrated in docker-compose.yml:

Profiles: - default: Core services (postgres, redis, api, admin, nginx) - monitoring: Prometheus, Grafana, Alertmanager, exporters

Networks: - changemaker-lite bridge network - Service discovery via container names

Volumes: - PostgreSQL data persistence - Redis data persistence - Uploads directory - Logs directory

Learn more \u2192

"},{"location":"v2/architecture/#nginx-routing","title":"Nginx Routing","text":"

Subdomain-based routing:

# Admin GUI\nserver {\n    server_name app.cmlite.org;\n    location / {\n        proxy_pass http://admin:3000;\n    }\n}\n\n# Express API\nserver {\n    server_name api.cmlite.org;\n    location / {\n        proxy_pass http://api:4000;\n    }\n}\n\n# Fastify Media API\nserver {\n    server_name media.cmlite.org;\n    location / {\n        proxy_pass http://media-api:4100;\n    }\n}\n

Learn more \u2192

"},{"location":"v2/architecture/#security-architecture","title":"Security Architecture","text":""},{"location":"v2/architecture/#authentication-flow","title":"Authentication Flow","text":"
sequenceDiagram\n    participant Client\n    participant API as Express API\n    participant DB as PostgreSQL\n    participant Redis\n\n    Client->>API: POST /api/auth/login\n    API->>DB: Verify credentials\n    DB-->>API: User record\n    API->>DB: Create refresh token (expires 7d)\n    API->>Redis: Rate limit check\n    API-->>Client: Access token (15min) + Refresh token (7d)\n\n    Note over Client: Access token expires\n\n    Client->>API: POST /api/auth/refresh\n    API->>DB: Validate refresh token\n    DB-->>API: Token valid\n    API->>DB: Rotate refresh token (transaction)\n    API-->>Client: New access token + New refresh token

Features: - bcrypt password hashing (12+ chars, complexity requirements) - JWT access tokens (15min expiry) - Refresh tokens (7 days, stored in DB, rotated on use) - Rate limiting (10 requests/min on auth endpoints) - User enumeration prevention (401 not 404) - RBAC middleware (requireRole, requireNonTemp)

Learn more \u2192

"},{"location":"v2/architecture/#security-layers","title":"Security Layers","text":"
  1. Network: Nginx rate limiting, fail2ban
  2. Application: Input validation (Zod schemas), RBAC
  3. Data: Encrypted fields (ENCRYPTION_KEY), SQL injection prevention (Prisma)
  4. Transport: HTTPS only (production), HSTS headers

Learn more \u2192

"},{"location":"v2/architecture/#scalability-considerations","title":"Scalability Considerations","text":""},{"location":"v2/architecture/#horizontal-scaling","title":"Horizontal Scaling","text":""},{"location":"v2/architecture/#vertical-scaling","title":"Vertical Scaling","text":""},{"location":"v2/architecture/#bottlenecks","title":"Bottlenecks","text":""},{"location":"v2/architecture/#monitoring-observability","title":"Monitoring & Observability","text":""},{"location":"v2/architecture/#golden-signals","title":"Golden Signals","text":"
  1. Latency: Request duration histograms
  2. Traffic: Request rate by endpoint
  3. Errors: Error rate (5xx responses)
  4. Saturation: Database connections, Redis memory, queue depth
"},{"location":"v2/architecture/#slos-service-level-objectives","title":"SLOs (Service Level Objectives)","text":""},{"location":"v2/architecture/#alerting-strategy","title":"Alerting Strategy","text":"

Learn more \u2192

"},{"location":"v2/architecture/#further-reading","title":"Further Reading","text":"

Next: Set up your development environment \u2192

"},{"location":"v2/architecture/authentication/","title":"Authentication Flow","text":"

Changemaker Lite V2 uses JWT-based authentication with access and refresh tokens for stateless, scalable authentication.

"},{"location":"v2/architecture/authentication/#overview","title":"Overview","text":"

Key Features:

"},{"location":"v2/architecture/authentication/#authentication-architecture","title":"Authentication Architecture","text":"
graph TB\n    subgraph \"Client Layer\"\n        Browser[Web Browser]\n        Storage[LocalStorage<br/>Zustand Persist]\n    end\n\n    subgraph \"API Layer\"\n        AuthRoutes[Auth Routes<br/>/api/auth/*]\n        AuthMiddleware[Auth Middleware<br/>JWT Verification]\n        RBACMiddleware[RBAC Middleware<br/>Role Check]\n    end\n\n    subgraph \"Data Layer\"\n        PG[(PostgreSQL<br/>User + RefreshToken)]\n        Redis[(Redis<br/>Rate Limiting)]\n    end\n\n    Browser -->|POST /auth/login| AuthRoutes\n    AuthRoutes -->|Check rate limit| Redis\n    AuthRoutes -->|Verify credentials| PG\n    AuthRoutes -->|Generate tokens| AuthRoutes\n    AuthRoutes -->|Store refresh token| PG\n    AuthRoutes -->|Return tokens| Browser\n    Browser -->|Store| Storage\n\n    Browser -->|API requests| AuthMiddleware\n    AuthMiddleware -->|Verify JWT| AuthMiddleware\n    AuthMiddleware -->|Check role| RBACMiddleware\n    RBACMiddleware -->|Authorized| Handler[Route Handler]\n\n    style AuthRoutes fill:#61dafb,stroke:#333,stroke-width:2px\n    style AuthMiddleware fill:#ffd700,stroke:#333,stroke-width:2px\n    style RBACMiddleware fill:#ff6b6b,stroke:#333,stroke-width:2px
"},{"location":"v2/architecture/authentication/#user-roles","title":"User Roles","text":""},{"location":"v2/architecture/authentication/#role-hierarchy","title":"Role Hierarchy","text":"
enum UserRole {\n  SUPER_ADMIN      = 'SUPER_ADMIN',      // Full system access\n  INFLUENCE_ADMIN  = 'INFLUENCE_ADMIN',  // Campaign management\n  MAP_ADMIN        = 'MAP_ADMIN',        // Location + canvassing management\n  USER             = 'USER',             // Standard user (limited access)\n  TEMP             = 'TEMP'              // Temporary user (public signups, auto-expires)\n}\n
"},{"location":"v2/architecture/authentication/#role-permissions","title":"Role Permissions","text":"Role Campaign CRUD Response Moderation Location Management User Management System Settings SUPER_ADMIN \u2705 \u2705 \u2705 \u2705 \u2705 INFLUENCE_ADMIN \u2705 \u2705 \u274c \u274c \u274c MAP_ADMIN \u274c \u274c \u2705 \u274c \u274c USER \u274c \u274c \u274c \u274c \u274c TEMP \u274c \u274c \u274c \u274c \u274c

TEMP User Behavior: - Created automatically for public shift signups - Auto-expires after configured days (expiresAt, expireDays fields) - Limited to volunteer canvassing features - Cannot access admin pages

"},{"location":"v2/architecture/authentication/#login-flow","title":"Login Flow","text":""},{"location":"v2/architecture/authentication/#sequence-diagram","title":"Sequence Diagram","text":"
sequenceDiagram\n    participant User\n    participant React as Admin GUI\n    participant Nginx\n    participant API as Express API\n    participant Redis\n    participant PG as PostgreSQL\n\n    User->>React: Enter email + password\n    React->>Nginx: POST /api/auth/login\n    Nginx->>API: Forward request\n\n    API->>Redis: Rate limit check (10/min)\n    alt Rate limit exceeded\n        Redis-->>API: Too many requests\n        API-->>React: 429 Too Many Requests\n        React-->>User: \"Try again later\"\n    else Rate limit OK\n        API->>PG: SELECT * FROM User WHERE email = ?\n        alt User not found\n            PG-->>API: null\n            API-->>React: 401 Unauthorized\n            React-->>User: \"Invalid credentials\"\n        else User found\n            PG-->>API: User record\n            API->>API: bcrypt.compare(password, hash)\n            alt Password invalid\n                API-->>React: 401 Unauthorized\n                React-->>User: \"Invalid credentials\"\n            else Password valid\n                API->>API: Check user status\n                alt Status SUSPENDED\n                    API-->>React: 403 Forbidden\n                    React-->>User: \"Account suspended\"\n                else Status ACTIVE\n                    API->>API: jwt.sign(accessPayload, 15min)\n                    API->>API: jwt.sign(refreshPayload, 7d)\n                    API->>PG: INSERT RefreshToken\n                    API->>PG: UPDATE lastLoginAt\n                    API-->>React: { user, accessToken, refreshToken }\n                    React->>React: Store in Zustand + localStorage\n                    React-->>User: Redirect to dashboard\n                end\n            end\n        end\n    end
"},{"location":"v2/architecture/authentication/#implementation","title":"Implementation","text":"

File: api/src/modules/auth/auth.service.ts (lines 22-56)

import bcrypt from 'bcryptjs';\nimport jwt from 'jsonwebtoken';\nimport { prisma } from '../../config/database';\nimport { loginSchema } from './auth.schemas';\nimport { incrementMetric } from '../../utils/metrics';\n\nexport async function login(credentials: { email: string; password: string }) {\n  // Validate input\n  const { email, password } = loginSchema.parse(credentials);\n\n  // Find user\n  const user = await prisma.user.findUnique({\n    where: { email },\n    select: {\n      id: true,\n      email: true,\n      password: true,\n      name: true,\n      role: true,\n      status: true,\n      emailVerified: true,\n      expiresAt: true\n    }\n  });\n\n  // User enumeration prevention: consistent 401 response\n  if (!user) {\n    throw new Error('Invalid credentials'); // Returns 401\n  }\n\n  // Verify password\n  const isValid = await bcrypt.compare(password, user.password);\n  if (!isValid) {\n    throw new Error('Invalid credentials'); // Returns 401\n  }\n\n  // Check user status\n  if (user.status === 'SUSPENDED') {\n    throw new Error('Account suspended'); // Returns 403\n  }\n  if (user.status === 'INACTIVE') {\n    throw new Error('Account inactive'); // Returns 403\n  }\n\n  // Check TEMP user expiration\n  if (user.expiresAt && new Date() > user.expiresAt) {\n    await prisma.user.update({\n      where: { id: user.id },\n      data: { status: 'EXPIRED' }\n    });\n    throw new Error('Account expired'); // Returns 403\n  }\n\n  // Generate access token (15 minutes)\n  const accessToken = jwt.sign(\n    { id: user.id, email: user.email, role: user.role },\n    process.env.JWT_ACCESS_SECRET!,\n    { expiresIn: '15m' as const }\n  );\n\n  // Generate refresh token (7 days)\n  const refreshToken = jwt.sign(\n    { id: user.id, type: 'refresh' },\n    process.env.JWT_REFRESH_SECRET!,\n    { expiresIn: '7d' as const }\n  );\n\n  // Store refresh token in database\n  await prisma.refreshToken.create({\n    data: {\n      token: refreshToken,\n      userId: user.id,\n      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days\n    }\n  });\n\n  // Update last login timestamp\n  await prisma.user.update({\n    where: { id: user.id },\n    data: { lastLoginAt: new Date() }\n  });\n\n  // Increment metrics\n  incrementMetric('cm_login_attempts_total', { status: 'success', role: user.role });\n\n  // Return user (no password) + tokens\n  const { password: _, ...userWithoutPassword } = user;\n  return {\n    user: userWithoutPassword,\n    accessToken,\n    refreshToken\n  };\n}\n
"},{"location":"v2/architecture/authentication/#password-policy","title":"Password Policy","text":"

Enforced at Zod schema level:

File: api/src/modules/auth/auth.schemas.ts (lines 9-16)

import { z } from 'zod';\n\nexport const passwordSchema = z\n  .string()\n  .min(12, 'Password must be at least 12 characters')\n  .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')\n  .regex(/[a-z]/, 'Password must contain at least one lowercase letter')\n  .regex(/[0-9]/, 'Password must contain at least one digit');\n\nexport const registerSchema = z.object({\n  email: z.string().email('Invalid email address'),\n  password: passwordSchema,\n  name: z.string().min(2, 'Name must be at least 2 characters')\n});\n\nexport const loginSchema = z.object({\n  email: z.string().email('Invalid email address'),\n  password: z.string().min(1, 'Password is required')\n});\n

Policy Requirements: - Minimum 12 characters - At least one uppercase letter (A-Z) - At least one lowercase letter (a-z) - At least one digit (0-9)

Note: Policy is NOT enforced on login (only on registration/password change) to avoid breaking existing accounts.

"},{"location":"v2/architecture/authentication/#refresh-token-flow","title":"Refresh Token Flow","text":""},{"location":"v2/architecture/authentication/#sequence-diagram_1","title":"Sequence Diagram","text":"
sequenceDiagram\n    participant React as Admin GUI\n    participant API as Express API\n    participant PG as PostgreSQL\n\n    Note over React: Access token expires (15min)\n    React->>React: Detect 401 Unauthorized\n    React->>API: POST /api/auth/refresh\n    Note right of React: Send refresh token\n\n    API->>API: jwt.verify(refreshToken)\n    alt Token invalid/expired\n        API-->>React: 401 Unauthorized\n        React->>React: Clear auth state\n        React-->>User: Redirect to login\n    else Token valid\n        API->>PG: BEGIN TRANSACTION\n        API->>PG: SELECT RefreshToken WHERE token = ?\n        alt Token not in database\n            API->>PG: ROLLBACK\n            API-->>React: 401 Unauthorized\n        else Token found\n            API->>PG: DELETE FROM RefreshToken WHERE token = ?\n            API->>API: Generate new access token (15min)\n            API->>API: Generate new refresh token (7d)\n            API->>PG: INSERT new RefreshToken\n            API->>PG: COMMIT TRANSACTION\n            API-->>React: { accessToken, refreshToken }\n            React->>React: Update stored tokens\n            React->>React: Retry original request\n        end\n    end
"},{"location":"v2/architecture/authentication/#implementation_1","title":"Implementation","text":"

File: api/src/modules/auth/auth.service.ts (lines 82-130)

export async function refreshTokens(refreshToken: string) {\n  // Verify refresh token signature\n  let payload: any;\n  try {\n    payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET!);\n  } catch (err) {\n    throw new Error('Invalid refresh token'); // Returns 401\n  }\n\n  // Atomic transaction for token rotation\n  const result = await prisma.$transaction(async (tx) => {\n    // Check if refresh token exists in database\n    const storedToken = await tx.refreshToken.findUnique({\n      where: { token: refreshToken },\n      include: { user: true }\n    });\n\n    if (!storedToken) {\n      throw new Error('Refresh token not found'); // Returns 401\n    }\n\n    // Check expiration\n    if (new Date() > storedToken.expiresAt) {\n      // Delete expired token\n      await tx.refreshToken.delete({\n        where: { token: refreshToken }\n      });\n      throw new Error('Refresh token expired'); // Returns 401\n    }\n\n    // Check user status\n    if (storedToken.user.status !== 'ACTIVE') {\n      throw new Error('User account not active'); // Returns 403\n    }\n\n    // Delete old refresh token (rotation)\n    await tx.refreshToken.delete({\n      where: { token: refreshToken }\n    });\n\n    // Generate new access token\n    const newAccessToken = jwt.sign(\n      { id: storedToken.user.id, email: storedToken.user.email, role: storedToken.user.role },\n      process.env.JWT_ACCESS_SECRET!,\n      { expiresIn: '15m' as const }\n    );\n\n    // Generate new refresh token\n    const newRefreshToken = jwt.sign(\n      { id: storedToken.user.id, type: 'refresh' },\n      process.env.JWT_REFRESH_SECRET!,\n      { expiresIn: '7d' as const }\n    );\n\n    // Store new refresh token\n    await tx.refreshToken.create({\n      data: {\n        token: newRefreshToken,\n        userId: storedToken.user.id,\n        expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)\n      }\n    });\n\n    return {\n      accessToken: newAccessToken,\n      refreshToken: newRefreshToken\n    };\n  });\n\n  return result;\n}\n

Critical: Refresh token rotation happens in a single database transaction to prevent race conditions (e.g., multiple refresh attempts).

"},{"location":"v2/architecture/authentication/#frontend-integration","title":"Frontend Integration","text":""},{"location":"v2/architecture/authentication/#zustand-auth-store","title":"Zustand Auth Store","text":"

File: admin/src/stores/auth.store.ts (lines 1-100)

import { create } from 'zustand';\nimport { persist } from 'zustand/middleware';\n\ninterface User {\n  id: string;\n  email: string;\n  name: string | null;\n  role: string;\n}\n\ninterface AuthState {\n  user: User | null;\n  accessToken: string | null;\n  refreshToken: string | null;\n  isAuthenticated: boolean;\n\n  login: (user: User, accessToken: string, refreshToken: string) => void;\n  logout: () => void;\n  updateTokens: (accessToken: string, refreshToken: string) => void;\n}\n\nexport const useAuthStore = create<AuthState>()(\n  persist(\n    (set) => ({\n      user: null,\n      accessToken: null,\n      refreshToken: null,\n      isAuthenticated: false,\n\n      login: (user, accessToken, refreshToken) => {\n        set({\n          user,\n          accessToken,\n          refreshToken,\n          isAuthenticated: true\n        });\n      },\n\n      logout: () => {\n        set({\n          user: null,\n          accessToken: null,\n          refreshToken: null,\n          isAuthenticated: false\n        });\n      },\n\n      updateTokens: (accessToken, refreshToken) => {\n        set({ accessToken, refreshToken });\n      }\n    }),\n    {\n      name: 'auth-storage', // LocalStorage key\n      partialize: (state) => ({\n        user: state.user,\n        accessToken: state.accessToken,\n        refreshToken: state.refreshToken,\n        isAuthenticated: state.isAuthenticated\n      })\n    }\n  )\n);\n
"},{"location":"v2/architecture/authentication/#axios-401-interceptor","title":"Axios 401 Interceptor","text":"

File: admin/src/lib/api.ts (lines 34-78)

import axios from 'axios';\nimport { useAuthStore } from '../stores/auth.store';\n\nexport const api = axios.create({\n  baseURL: '/api',\n  headers: {\n    'Content-Type': 'application/json'\n  }\n});\n\n// Request interceptor: Add access token to all requests\napi.interceptors.request.use((config) => {\n  const { accessToken } = useAuthStore.getState();\n  if (accessToken) {\n    config.headers.Authorization = `Bearer ${accessToken}`;\n  }\n  return config;\n});\n\n// Response interceptor: Handle 401 with token refresh\nlet isRefreshing = false;\nlet refreshCallbacks: ((token: string) => void)[] = [];\n\napi.interceptors.response.use(\n  (response) => response,\n  async (error) => {\n    const originalRequest = error.config;\n\n    // If 401 and we haven't tried refreshing yet\n    if (error.response?.status === 401 && !originalRequest._retry) {\n      originalRequest._retry = true;\n\n      const { refreshToken, updateTokens, logout } = useAuthStore.getState();\n\n      if (!refreshToken) {\n        logout();\n        window.location.href = '/login';\n        return Promise.reject(error);\n      }\n\n      // Deduplicate refresh requests (only one refresh at a time)\n      if (isRefreshing) {\n        // Wait for ongoing refresh to complete\n        return new Promise((resolve) => {\n          refreshCallbacks.push((token: string) => {\n            originalRequest.headers.Authorization = `Bearer ${token}`;\n            resolve(api(originalRequest));\n          });\n        });\n      }\n\n      isRefreshing = true;\n\n      try {\n        // Refresh tokens\n        const { data } = await axios.post('/api/auth/refresh', { refreshToken });\n\n        // Update stored tokens\n        updateTokens(data.accessToken, data.refreshToken);\n\n        // Retry original request with new token\n        originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;\n\n        // Resolve queued requests\n        refreshCallbacks.forEach((callback) => callback(data.accessToken));\n        refreshCallbacks = [];\n\n        return api(originalRequest);\n      } catch (refreshError) {\n        // Refresh failed, logout\n        logout();\n        window.location.href = '/login';\n        return Promise.reject(refreshError);\n      } finally {\n        isRefreshing = false;\n      }\n    }\n\n    return Promise.reject(error);\n  }\n);\n

Key Features: - Automatic token refresh on 401 - Deduplicates concurrent refresh requests (callback queue) - Retries original request after refresh - Logs out on refresh failure

"},{"location":"v2/architecture/authentication/#middleware","title":"Middleware","text":""},{"location":"v2/architecture/authentication/#jwt-verification","title":"JWT Verification","text":"

File: api/src/middleware/auth.ts (lines 1-35)

import { Request, Response, NextFunction } from 'express';\nimport jwt from 'jsonwebtoken';\n\nexport interface AuthUser {\n  id: string;\n  email: string;\n  role: string;\n}\n\ndeclare global {\n  namespace Express {\n    interface Request {\n      user?: AuthUser;\n    }\n  }\n}\n\nexport const authenticate = (req: Request, res: Response, next: NextFunction) => {\n  const authHeader = req.headers.authorization;\n\n  if (!authHeader || !authHeader.startsWith('Bearer ')) {\n    return res.status(401).json({ error: 'No token provided' });\n  }\n\n  const token = authHeader.split(' ')[1];\n\n  try {\n    const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET!) as AuthUser;\n    req.user = payload; // Attach user to request\n    next();\n  } catch (err) {\n    return res.status(401).json({ error: 'Invalid or expired token' });\n  }\n};\n
"},{"location":"v2/architecture/authentication/#role-based-access-control-rbac","title":"Role-Based Access Control (RBAC)","text":"

File: api/src/middleware/auth.ts (lines 37-55)

export const requireRole = (...allowedRoles: string[]) => {\n  return (req: Request, res: Response, next: NextFunction) => {\n    if (!req.user) {\n      return res.status(401).json({ error: 'Not authenticated' });\n    }\n\n    if (!allowedRoles.includes(req.user.role)) {\n      return res.status(403).json({\n        error: 'Insufficient permissions',\n        required: allowedRoles,\n        current: req.user.role\n      });\n    }\n\n    next();\n  };\n};\n\n// Block TEMP users from specific routes\nexport const requireNonTemp = (req: Request, res: Response, next: NextFunction) => {\n  if (req.user?.role === 'TEMP') {\n    return res.status(403).json({ error: 'Temporary users cannot access this resource' });\n  }\n  next();\n};\n

Usage:

import { authenticate, requireRole, requireNonTemp } from './middleware/auth';\n\n// Require authentication\nrouter.get('/profile', authenticate, getProfile);\n\n// Require specific role\nrouter.post('/campaigns', authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'), createCampaign);\n\n// Block TEMP users\nrouter.post('/users', authenticate, requireNonTemp, createUser);\n
"},{"location":"v2/architecture/authentication/#rate-limiting","title":"Rate Limiting","text":"

File: api/src/middleware/rate-limit.ts (lines 1-45)

import rateLimit from 'express-rate-limit';\nimport RedisStore from 'rate-limit-redis';\nimport { redis } from '../config/redis';\n\n// Auth endpoints: 10 requests per minute\nexport const authRateLimit = rateLimit({\n  store: new RedisStore({\n    client: redis,\n    prefix: 'rl:auth:',\n    sendCommand: (...args: string[]) => redis.call(...args)\n  }),\n  windowMs: 60 * 1000, // 1 minute\n  max: 10,\n  message: 'Too many auth requests, please try again later',\n  standardHeaders: true,\n  legacyHeaders: false\n});\n\n// Apply to auth routes\nimport authRoutes from './modules/auth/auth.routes';\napp.use('/api/auth/login', authRateLimit);\napp.use('/api/auth/register', authRateLimit);\napp.use('/api/auth/refresh', authRateLimit);\n
"},{"location":"v2/architecture/authentication/#security-features","title":"Security Features","text":""},{"location":"v2/architecture/authentication/#1-user-enumeration-prevention","title":"1. User Enumeration Prevention","text":"

Problem: Attackers can enumerate valid emails by observing different error messages.

Solution: Consistent 401 response for both \"user not found\" and \"invalid password\":

if (!user) {\n  throw new Error('Invalid credentials'); // Same message\n}\n\nif (!isValidPassword) {\n  throw new Error('Invalid credentials'); // Same message\n}\n
"},{"location":"v2/architecture/authentication/#2-password-hashing","title":"2. Password Hashing","text":"

bcryptjs with automatic salt generation:

import bcrypt from 'bcryptjs';\n\n// Registration\nconst hashedPassword = await bcrypt.hash(password, 10); // 10 rounds\nawait prisma.user.create({\n  data: { email, password: hashedPassword, name, role: 'USER' }\n});\n\n// Login\nconst isValid = await bcrypt.compare(password, user.password);\n

Rounds: 10 (balanced between security and performance)

"},{"location":"v2/architecture/authentication/#3-refresh-token-rotation","title":"3. Refresh Token Rotation","text":"

Prevents replay attacks:

"},{"location":"v2/architecture/authentication/#4-token-expiration","title":"4. Token Expiration","text":"Token Type Lifetime Storage Purpose Access 15 minutes Not stored (JWT only) API authentication Refresh 7 days Database + localStorage Token renewal

Short access token lifetime limits damage if token is stolen.

"},{"location":"v2/architecture/authentication/#5-redis-authentication","title":"5. Redis Authentication","text":"

Redis requires password authentication:

# .env\nREDIS_PASSWORD=strong_password_here\n\n# Redis connection\nREDIS_URL=redis://:${REDIS_PASSWORD}@redis-changemaker:6379\n
"},{"location":"v2/architecture/authentication/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/architecture/authentication/#login-fails-with-correct-password","title":"Login Fails with Correct Password","text":"

Cause: User status not ACTIVE, or TEMP user expired.

Solution:

-- Check user status\nSELECT email, status, expiresAt FROM \"User\" WHERE email = 'user@example.com';\n\n-- Activate user\nUPDATE \"User\" SET status = 'ACTIVE' WHERE email = 'user@example.com';\n
"},{"location":"v2/architecture/authentication/#token-refresh-fails","title":"Token Refresh Fails","text":"

Cause: Refresh token not in database (deleted or expired).

Solution:

-- Check if refresh token exists\nSELECT * FROM \"RefreshToken\" WHERE token = 'token_here';\n\n-- Delete all expired tokens\nDELETE FROM \"RefreshToken\" WHERE \"expiresAt\" < NOW();\n
"},{"location":"v2/architecture/authentication/#401-on-all-requests","title":"401 on All Requests","text":"

Cause: Access token missing, invalid, or expired.

Debug:

# Decode JWT (without verifying signature)\necho \"eyJhbG...\" | cut -d'.' -f2 | base64 -d | jq\n\n# Check expiration\n# Look for \"exp\" field (Unix timestamp)\n
"},{"location":"v2/architecture/authentication/#circular-dependency-authstore-apits","title":"Circular Dependency (auth.store \u2194 api.ts)","text":"

Problem: auth.store imports api.ts, api.ts imports auth.store (circular).

Solution: Callback registration pattern (already implemented in api.ts lines 34-78).

"},{"location":"v2/architecture/authentication/#further-reading","title":"Further Reading","text":""},{"location":"v2/architecture/dual-api/","title":"Dual API Architecture","text":"

Changemaker Lite V2 uses a dual API architecture with Express.js for main features and Fastify for the media library microservice.

"},{"location":"v2/architecture/dual-api/#why-dual-api","title":"Why Dual API?","text":""},{"location":"v2/architecture/dual-api/#performance-isolation","title":"Performance Isolation","text":"

Media operations (video processing, large uploads) are isolated from core platform features:

"},{"location":"v2/architecture/dual-api/#technology-evaluation","title":"Technology Evaluation","text":"

V2 evaluates two popular Node.js frameworks side-by-side:

Feature Express.js Fastify Ecosystem Massive (15+ years) Growing (7+ years) Performance Good Excellent (2-3x faster) TypeScript Requires @types/* Native support Middleware Industry standard Plugin system Use Case General purpose High-throughput APIs"},{"location":"v2/architecture/dual-api/#independent-scaling","title":"Independent Scaling","text":"

Each API can scale independently:

"},{"location":"v2/architecture/dual-api/#clear-service-boundaries","title":"Clear Service Boundaries","text":"

Microservice preparation without full microservices complexity:

"},{"location":"v2/architecture/dual-api/#architecture-diagram","title":"Architecture Diagram","text":"
graph TB\n    subgraph \"Client Layer\"\n        Browser[Web Browser]\n        Mobile[Mobile App]\n    end\n\n    subgraph \"Proxy Layer\"\n        Nginx[Nginx Reverse Proxy<br/>Port 80/443]\n    end\n\n    subgraph \"API Layer\"\n        Express[Express API<br/>Port 4000<br/>Prisma ORM<br/>27+ Models]\n        Fastify[Fastify Media API<br/>Port 4100<br/>Drizzle ORM<br/>Media Tables]\n    end\n\n    subgraph \"Data Layer\"\n        PG[(PostgreSQL 16<br/>changemaker_v2 DB)]\n        Redis[(Redis 7<br/>Cache + Queues)]\n    end\n\n    subgraph \"External Services\"\n        SMTP[SMTP Server]\n        Represent[Represent API]\n        Geocoding[Geocoding APIs]\n        Listmonk[Listmonk]\n    end\n\n    Browser --> Nginx\n    Mobile --> Nginx\n\n    Nginx -->|/api/* except /api/media/*| Express\n    Nginx -->|/api/media/*| Fastify\n\n    Express --> PG\n    Express --> Redis\n    Express --> SMTP\n    Express --> Represent\n    Express --> Geocoding\n    Express --> Listmonk\n\n    Fastify --> PG\n    Fastify --> Redis\n\n    style Express fill:#61dafb,stroke:#333,stroke-width:2px\n    style Fastify fill:#00d562,stroke:#333,stroke-width:2px\n    style PG fill:#336791,stroke:#333,stroke-width:2px\n    style Redis fill:#dc382d,stroke:#333,stroke-width:2px
"},{"location":"v2/architecture/dual-api/#express-api-main-features","title":"Express API (Main Features)","text":""},{"location":"v2/architecture/dual-api/#entry-point","title":"Entry Point","text":"

File: api/src/server.ts (234 lines)

import express from 'express';\nimport cors from 'cors';\nimport helmet from 'helmet';\nimport { errorHandler } from './middleware/error-handler';\nimport { authenticate } from './middleware/auth';\nimport { metricsMiddleware } from './utils/metrics';\n\nconst app = express();\n\n// Global middleware\napp.use(helmet());\napp.use(cors({ origin: process.env.CORS_ORIGIN, credentials: true }));\napp.use(express.json({ limit: '50mb' }));\napp.use(metricsMiddleware);\n\n// Health check (no auth)\napp.get('/api/health', (req, res) => {\n  res.json({ status: 'healthy', timestamp: new Date().toISOString() });\n});\n\n// Metrics endpoint (no auth, for Prometheus)\napp.get('/api/metrics', async (req, res) => {\n  res.set('Content-Type', register.contentType);\n  res.end(await register.metrics());\n});\n\n// Route registration (40+ route groups)\napp.use('/api/auth', authRoutes);\napp.use('/api/users', authenticate, usersRoutes);\napp.use('/api/settings', authenticate, settingsRoutes);\napp.use('/api/campaigns', campaignsRoutes); // Public + admin routes\napp.use('/api/representatives', representativesRoutes);\napp.use('/api/responses', responsesRoutes); // Public + admin + moderation\n// ... 35+ more route groups\n\n// Global error handler (must be last)\napp.use(errorHandler);\n\nconst PORT = process.env.API_PORT || 4000;\napp.listen(PORT, () => {\n  logger.info(`Express API listening on port ${PORT}`);\n});\n
"},{"location":"v2/architecture/dual-api/#key-features","title":"Key Features","text":"

14 Feature Modules:

  1. auth - JWT login, register, refresh, logout
  2. users - User CRUD with pagination + search
  3. settings - Site settings singleton
  4. campaigns - Campaign CRUD + public routes
  5. representatives - Represent API integration
  6. responses - Response wall + moderation + upvoting
  7. email-queue - BullMQ queue admin
  8. campaign-emails - Email tracking + stats
  9. postal-codes - Postal code cache
  10. locations - Location CRUD + geocoding + NAR import
  11. cuts - Cut (polygon) CRUD + spatial queries
  12. shifts - Shift CRUD + signups
  13. canvass - Volunteer canvassing (sessions, visits, routes)
  14. pages - Landing page builder (GrapesJS)

Plus: email-templates, listmonk, pangolin, docs, qr, services, observability

"},{"location":"v2/architecture/dual-api/#architecture-pattern","title":"Architecture Pattern","text":"

Layered Structure:

api/src/modules/{module}/\n\u251c\u2500\u2500 {module}.routes.ts       # Express router + middleware\n\u251c\u2500\u2500 {module}.service.ts      # Business logic + database queries\n\u251c\u2500\u2500 {module}.schemas.ts      # Zod validation schemas\n\u2514\u2500\u2500 {module}.types.ts        # TypeScript interfaces (optional)\n

Example: Campaign Module

// campaigns.routes.ts\nimport { Router } from 'express';\nimport { validate } from '../../middleware/validate';\nimport { authenticate, requireRole } from '../../middleware/auth';\nimport { createCampaignSchema, updateCampaignSchema } from './campaigns.schemas';\nimport * as campaignService from './campaigns.service';\n\nconst router = Router();\n\n// Admin routes (auth required)\nrouter.post('/',\n  authenticate,\n  requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'),\n  validate(createCampaignSchema),\n  async (req, res) => {\n    const campaign = await campaignService.createCampaign(req.body, req.user!.id);\n    res.status(201).json(campaign);\n  }\n);\n\n// Public routes (no auth)\nrouter.get('/:id', async (req, res) => {\n  const campaign = await campaignService.getCampaignById(req.params.id);\n  res.json(campaign);\n});\n\nexport default router;\n
"},{"location":"v2/architecture/dual-api/#orm-prisma","title":"ORM: Prisma","text":"

27+ Models in api/prisma/schema.prisma:

model Campaign {\n  id                    String   @id @default(cuid())\n  slug                  String   @unique\n  title                 String\n  description           String?  @db.Text\n  emailSubject          String\n  emailBody             String   @db.Text\n  status                CampaignStatus @default(DRAFT)\n\n  // Feature flags\n  allowSmtpEmail        Boolean  @default(true)\n  showResponseWall      Boolean  @default(true)\n\n  // Audit fields\n  createdByUserId       String?\n  createdByUser         User?    @relation(fields: [createdByUserId], references: [id], onDelete: SetNull)\n  createdAt             DateTime @default(now())\n  updatedAt             DateTime @updatedAt\n\n  // Relations\n  emails                CampaignEmail[]\n  responses             RepresentativeResponse[]\n  customRecipients      CustomRecipient[]\n}\n

Connection Pooling:

Prisma manages connection pool automatically:

// prisma/schema.prisma\ndatasource db {\n  provider = \"postgresql\"\n  url      = env(\"DATABASE_URL\")\n}\n\n// Default pool size: 10 connections per instance\n// Configure via DATABASE_URL: ?connection_limit=20\n
"},{"location":"v2/architecture/dual-api/#fastify-api-media-library","title":"Fastify API (Media Library)","text":""},{"location":"v2/architecture/dual-api/#entry-point_1","title":"Entry Point","text":"

File: api/src/media-server.ts (104 lines)

import Fastify from 'fastify';\nimport cors from '@fastify/cors';\nimport helmet from '@fastify/helmet';\nimport { videosRoutes } from './modules/media/videos/videos.routes';\nimport { sharedMediaRoutes } from './modules/media/shared-media/shared-media.routes';\nimport { jobsRoutes } from './modules/media/jobs/jobs.routes';\nimport { reactionsRoutes } from './modules/media/reactions/reactions.routes';\n\nconst fastify = Fastify({\n  logger: {\n    level: process.env.NODE_ENV === 'production' ? 'info' : 'debug'\n  }\n});\n\n// Plugins\nawait fastify.register(cors, {\n  origin: process.env.CORS_ORIGIN,\n  credentials: true\n});\nawait fastify.register(helmet);\n\n// Health check\nfastify.get('/health', async (request, reply) => {\n  return { status: 'healthy', timestamp: new Date().toISOString() };\n});\n\n// Route registration\nfastify.register(videosRoutes, { prefix: '/api/media/videos' });\nfastify.register(sharedMediaRoutes, { prefix: '/api/media/shared' });\nfastify.register(jobsRoutes, { prefix: '/api/media/jobs' });\nfastify.register(reactionsRoutes, { prefix: '/api/media/reactions' });\n\nconst PORT = Number(process.env.MEDIA_API_PORT) || 4100;\nawait fastify.listen({ port: PORT, host: '0.0.0.0' });\nfastify.log.info(`Fastify Media API listening on port ${PORT}`);\n
"},{"location":"v2/architecture/dual-api/#key-features_1","title":"Key Features","text":"

4 Feature Modules:

  1. videos - Video CRUD, metadata, tags, deduplication
  2. shared-media - Public gallery categories (videos, curated, compilations, etc.)
  3. jobs - Job queue monitoring (pending, running, completed, failed)
  4. reactions - Reaction system (6 standard emojis: like, love, laugh, wow, sad, angry)
"},{"location":"v2/architecture/dual-api/#architecture-pattern_1","title":"Architecture Pattern","text":"

Plugin-Based:

// videos.routes.ts\nimport { FastifyPluginAsync } from 'fastify';\nimport { verifyJWT } from '../../middleware/auth';\nimport { getVideosSchema, createVideoSchema } from './videos.schemas';\n\nexport const videosRoutes: FastifyPluginAsync = async (fastify) => {\n  // Middleware: JWT verification\n  fastify.addHook('onRequest', verifyJWT);\n\n  // GET /api/media/videos\n  fastify.get('/', {\n    schema: getVideosSchema,\n    handler: async (request, reply) => {\n      const videos = await getVideos(request.query);\n      return videos;\n    }\n  });\n\n  // POST /api/media/videos\n  fastify.post('/', {\n    schema: createVideoSchema,\n    handler: async (request, reply) => {\n      const video = await createVideo(request.body);\n      return reply.status(201).send(video);\n    }\n  });\n};\n
"},{"location":"v2/architecture/dual-api/#orm-drizzle","title":"ORM: Drizzle","text":"

Media Tables in api/src/modules/media/db/schema.ts:

import { pgTable, serial, text, integer, boolean, timestamp, jsonb } from 'drizzle-orm/pg-core';\n\nexport const videos = pgTable('videos', {\n  id: serial('id').primaryKey(),\n  path: text('path').unique().notNull(),\n  filename: text('filename').notNull(),\n  producer: text('producer'),\n  creator: text('creator'),\n  title: text('title'),\n  durationSeconds: integer('duration_seconds'),\n  width: integer('width'),\n  height: integer('height'),\n  orientation: text('orientation'), // 'landscape' | 'portrait' | 'square'\n  hasAudio: boolean('has_audio').default(true),\n  fileSize: integer('file_size'),\n  thumbnailPath: text('thumbnail_path'),\n  tags: jsonb('tags').$type<string[]>(),\n  isValid: boolean('is_valid').default(true),\n  createdAt: timestamp('created_at').defaultNow(),\n}, (table) => ({\n  orientationIdx: index('idx_orientation').on(table.orientation),\n  producerIdx: index('idx_producer').on(table.producer),\n}));\n

Connection:

Drizzle uses the same PostgreSQL connection pool:

import { drizzle } from 'drizzle-orm/node-postgres';\nimport { Pool } from 'pg';\n\nconst pool = new Pool({\n  connectionString: process.env.DATABASE_URL,\n  max: 10\n});\n\nexport const db = drizzle(pool);\n
"},{"location":"v2/architecture/dual-api/#request-flow","title":"Request Flow","text":""},{"location":"v2/architecture/dual-api/#public-campaign-email-submission","title":"Public Campaign Email Submission","text":"
sequenceDiagram\n    participant User as User Browser\n    participant Nginx\n    participant React as Admin GUI\n    participant Express as Express API\n    participant PG as PostgreSQL\n    participant Redis\n    participant BullMQ\n    participant SMTP\n\n    User->>React: Visit /campaigns/123\n    React->>Nginx: GET /campaigns/123\n    Nginx->>React: Serve React app\n    React->>Nginx: GET /api/campaigns/123\n    Nginx->>Express: Forward to Express\n    Express->>PG: SELECT campaign\n    PG-->>Express: Campaign data\n    Express-->>React: Campaign JSON\n    React-->>User: Render page\n\n    User->>React: Submit email form\n    React->>Nginx: POST /api/campaigns/123/send-email\n    Nginx->>Express: Forward to Express\n    Express->>Express: Rate limit check (30/hour)\n    Express->>PG: INSERT CampaignEmail\n    Express->>BullMQ: Enqueue job\n    BullMQ->>Redis: Add job to queue\n    Express-->>React: Success response\n    React-->>User: \"Email queued\"\n\n    BullMQ->>Express: Process job (worker)\n    Express->>PG: SELECT email + campaign\n    Express->>Express: Build SMTP message\n    Express->>SMTP: Send email\n    SMTP-->>Express: Delivery confirmed\n    Express->>PG: UPDATE status = SENT\n    Express->>Redis: Increment cm_emails_sent_total
"},{"location":"v2/architecture/dual-api/#admin-media-upload","title":"Admin Media Upload","text":"
sequenceDiagram\n    participant Admin as Admin Browser\n    participant Nginx\n    participant Fastify as Fastify Media API\n    participant PG as PostgreSQL\n    participant FS as File System\n\n    Admin->>Nginx: POST /api/media/videos (10GB file)\n    Nginx->>Fastify: Stream upload (no buffering)\n    Fastify->>FS: Save to /media/videos/\n    Fastify->>PG: INSERT video metadata\n    PG-->>Fastify: Video record\n    Fastify-->>Admin: { id, path, thumbnail }

Key Difference: - Express handles small JSON payloads (campaigns, locations, users) - Fastify handles large file uploads (streaming, no buffering)

"},{"location":"v2/architecture/dual-api/#shared-resources","title":"Shared Resources","text":""},{"location":"v2/architecture/dual-api/#postgresql-database","title":"PostgreSQL Database","text":"

Single Database, Multiple Schemas:

Both ORMs connect to the same changemaker_v2 database:

DATABASE_URL=postgresql://changemaker:password@v2-postgres:5432/changemaker_v2\n

No Conflicts: - Prisma manages its own schema via migrations (npx prisma migrate) - Drizzle manages media tables via npx drizzle-kit push - Tables don't overlap (different prefixes)

"},{"location":"v2/architecture/dual-api/#redis-cache","title":"Redis Cache","text":"

Both APIs use Redis for:

// Shared Redis connection\nimport Redis from 'ioredis';\n\nexport const redis = new Redis({\n  host: 'redis-changemaker',\n  port: 6379,\n  password: process.env.REDIS_PASSWORD,\n  maxRetriesPerRequest: 3\n});\n
"},{"location":"v2/architecture/dual-api/#jwt-authentication","title":"JWT Authentication","text":"

Both APIs verify the same JWT tokens:

// Express: api/src/middleware/auth.ts\nimport jwt from 'jsonwebtoken';\n\nexport const authenticate = (req, res, next) => {\n  const token = req.headers.authorization?.split(' ')[1];\n  const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET);\n  req.user = payload; // { id, email, role }\n  next();\n};\n\n// Fastify: api/src/modules/media/middleware/auth.ts\nimport jwt from 'jsonwebtoken';\n\nexport const verifyJWT = async (request, reply) => {\n  const token = request.headers.authorization?.split(' ')[1];\n  const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET);\n  request.user = payload;\n};\n

Shared Secret: JWT_ACCESS_SECRET environment variable

"},{"location":"v2/architecture/dual-api/#nginx-routing","title":"Nginx Routing","text":""},{"location":"v2/architecture/dual-api/#location-block-ordering","title":"Location Block Ordering","text":"

Critical: Media API location must come BEFORE general API location:

server {\n    listen 80;\n    server_name api.cmlite.org;\n\n    # Media API (longest prefix first)\n    location /api/media/ {\n        proxy_pass http://changemaker-media-api:4100;\n        client_max_body_size 10G;\n    }\n\n    # Express API (catch-all)\n    location /api/ {\n        proxy_pass http://changemaker-v2-api:4000;\n    }\n}\n

Why Order Matters:

Nginx matches longest prefix first. If /api/ came first, it would match /api/media/videos and route to Express (wrong).

"},{"location":"v2/architecture/dual-api/#subdomain-routing-production","title":"Subdomain Routing (Production)","text":"
# Express API\nserver {\n    listen 80;\n    server_name api.cmlite.org;\n    location / {\n        proxy_pass http://changemaker-v2-api:4000;\n    }\n}\n\n# Fastify Media API\nserver {\n    listen 80;\n    server_name media.cmlite.org;\n    location / {\n        proxy_pass http://changemaker-media-api:4100;\n    }\n}\n
"},{"location":"v2/architecture/dual-api/#performance-comparison","title":"Performance Comparison","text":""},{"location":"v2/architecture/dual-api/#benchmarks-internal-testing","title":"Benchmarks (Internal Testing)","text":"

Simple GET Request (JSON response):

Framework Requests/sec Latency p95 Memory Express 12,500 35ms 150MB Fastify 28,000 15ms 120MB

Large Upload (1GB file):

Framework Upload Time Memory Peak CPU Usage Express 45s 450MB 85% Fastify 38s 280MB 60%

Real-World Usage:

"},{"location":"v2/architecture/dual-api/#future-full-microservices","title":"Future: Full Microservices","text":"

The dual API design prepares for future microservices migration:

"},{"location":"v2/architecture/dual-api/#potential-split","title":"Potential Split","text":"
\u251c\u2500\u2500 campaign-service/     # Express API (Influence module)\n\u251c\u2500\u2500 map-service/          # Express API (Map module)\n\u251c\u2500\u2500 media-service/        # Fastify API (Media module)\n\u251c\u2500\u2500 auth-service/         # Shared authentication\n\u2514\u2500\u2500 api-gateway/          # Nginx or Kong\n
"},{"location":"v2/architecture/dual-api/#benefits","title":"Benefits","text":""},{"location":"v2/architecture/dual-api/#trade-offs","title":"Trade-offs","text":"

V2 Strategy: Keep dual API until scaling requires split (likely 10,000+ users).

"},{"location":"v2/architecture/dual-api/#development-workflow","title":"Development Workflow","text":""},{"location":"v2/architecture/dual-api/#running-both-apis","title":"Running Both APIs","text":"
# Terminal 1: Express API\ncd api && npm run dev  # Port 4000\n\n# Terminal 2: Fastify Media API\ncd api && npm run dev:media  # Port 4100\n\n# Terminal 3: Admin GUI\ncd admin && npm run dev  # Port 3000\n
"},{"location":"v2/architecture/dual-api/#docker-compose","title":"Docker Compose","text":"
# Start both APIs\ndocker compose up -d api media-api\n\n# View logs\ndocker compose logs -f api\ndocker compose logs -f media-api\n\n# Rebuild after dependency changes\ndocker compose build api media-api\ndocker compose up -d api media-api\n
"},{"location":"v2/architecture/dual-api/#monitoring","title":"Monitoring","text":"

Both APIs expose Prometheus metrics:

Custom Metrics:

// Express: api/src/utils/metrics.ts\nimport client from 'prom-client';\n\nexport const httpRequestTotal = new client.Counter({\n  name: 'http_request_total',\n  help: 'Total HTTP requests',\n  labelNames: ['method', 'route', 'status']\n});\n\nexport const emailsSentTotal = new client.Counter({\n  name: 'cm_emails_sent_total',\n  help: 'Total campaign emails sent'\n});\n\n// Fastify: api/src/modules/media/metrics.ts\nexport const mediaUploadsTotal = new client.Counter({\n  name: 'cm_media_uploads_total',\n  help: 'Total media uploads',\n  labelNames: ['type']\n});\n

Prometheus scrapes both endpoints every 15 seconds.

"},{"location":"v2/architecture/dual-api/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/architecture/dual-api/#media-api-returns-404","title":"Media API Returns 404","text":"

Cause: Nginx routing issue (order of location blocks).

Fix: Ensure /api/media/ comes BEFORE /api/ in nginx config.

"},{"location":"v2/architecture/dual-api/#large-upload-fails-413","title":"Large Upload Fails (413)","text":"

Cause: client_max_body_size too small.

Fix: Increase in nginx config:

location /api/media/ {\n    client_max_body_size 20G;  # Increase from default\n}\n
"},{"location":"v2/architecture/dual-api/#connection-pool-exhausted","title":"Connection Pool Exhausted","text":"

Cause: Too many concurrent requests, not enough DB connections.

Fix: Increase connection limit in DATABASE_URL:

DATABASE_URL=postgresql://user:pass@host:5432/db?connection_limit=20\n

Or reduce pool size per API instance (if running multiple):

// Prisma\ndatasource db {\n  url = env(\"DATABASE_URL\")  // Add ?connection_limit=5 for smaller pool\n}\n\n// Drizzle\nconst pool = new Pool({ max: 5 });\n
"},{"location":"v2/architecture/dual-api/#jwt-verification-fails-across-apis","title":"JWT Verification Fails Across APIs","text":"

Cause: Different JWT_ACCESS_SECRET values.

Fix: Ensure both APIs use the same secret:

# .env\nJWT_ACCESS_SECRET=<same-value-for-both>\n
"},{"location":"v2/architecture/dual-api/#further-reading","title":"Further Reading","text":""},{"location":"v2/backend/","title":"Backend Overview","text":"

The Changemaker Lite V2 backend is a dual-API architecture built with TypeScript, providing a robust foundation for campaign management, mapping, and media services.

"},{"location":"v2/backend/#architecture","title":"Architecture","text":"

The backend consists of two complementary API servers:

Both APIs share a common PostgreSQL 16 database but use different ORM approaches for their specific needs. The Express API handles the majority of business logic, while the Fastify API is optimized for media operations.

"},{"location":"v2/backend/#key-components","title":"Key Components","text":""},{"location":"v2/backend/#modules","title":"Modules","text":"

Backend modules provide feature-specific functionality across authentication, campaigns, locations, media, and more. Each module follows a consistent pattern with schemas, services, and routes.

"},{"location":"v2/backend/#services","title":"Services","text":"

Shared services provide cross-cutting concerns like email delivery, geocoding, queue management, and external API integrations.

"},{"location":"v2/backend/#middleware","title":"Middleware","text":"

Middleware components handle authentication, authorization, rate limiting, validation, and error handling across all API endpoints.

"},{"location":"v2/backend/#utilities","title":"Utilities","text":"

Utility modules provide common functionality for spatial calculations, logging, metrics collection, and data processing.

"},{"location":"v2/backend/#technology-stack","title":"Technology Stack","text":""},{"location":"v2/backend/#api-structure","title":"API Structure","text":"
api/\n\u251c\u2500\u2500 src/\n\u2502   \u251c\u2500\u2500 server.ts              # Express API entry point (port 4000)\n\u2502   \u251c\u2500\u2500 media-server.ts        # Fastify media API (port 4100)\n\u2502   \u251c\u2500\u2500 config/\n\u2502   \u2502   \u2514\u2500\u2500 env.ts             # Environment configuration\n\u2502   \u251c\u2500\u2500 middleware/            # Auth, RBAC, validation, rate limiting\n\u2502   \u251c\u2500\u2500 modules/               # Feature modules\n\u2502   \u251c\u2500\u2500 services/              # Shared services\n\u2502   \u251c\u2500\u2500 types/                 # TypeScript definitions\n\u2502   \u2514\u2500\u2500 utils/                 # Helper utilities\n\u251c\u2500\u2500 prisma/\n\u2502   \u251c\u2500\u2500 schema.prisma          # Main database schema (30+ models)\n\u2502   \u2514\u2500\u2500 migrations/            # Database migrations\n\u2514\u2500\u2500 drizzle/                   # Media API schema\n
"},{"location":"v2/backend/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/backend/#quick-links","title":"Quick Links","text":""},{"location":"v2/backend/middleware/","title":"Backend Middleware","text":"

Middleware components provide cross-cutting concerns for authentication, authorization, validation, rate limiting, and error handling across all API endpoints.

"},{"location":"v2/backend/middleware/#middleware-architecture","title":"Middleware Architecture","text":"

Express middleware functions are composed in the request pipeline to:

"},{"location":"v2/backend/middleware/#core-middleware","title":"Core Middleware","text":""},{"location":"v2/backend/middleware/#authentication","title":"Authentication","text":"

authenticate (middleware/auth.ts)

router.get('/profile', authenticate, async (req, res) => {\n  // req.user is guaranteed to exist\n  const userId = req.user.id;\n});\n
"},{"location":"v2/backend/middleware/#authorization","title":"Authorization","text":"

requireRole (middleware/auth.ts)

router.post(\n  '/campaigns',\n  authenticate,\n  requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'),\n  async (req, res) => {\n    // Only admins can create campaigns\n  }\n);\n

requireNonTemp (middleware/auth.ts)

router.post(\n  '/shifts/:id/signup',\n  authenticate,\n  requireNonTemp,\n  async (req, res) => {\n    // TEMP users cannot sign up for shifts\n  }\n);\n
"},{"location":"v2/backend/middleware/#validation","title":"Validation","text":"

validate (middleware/validate.ts)

import { validate } from '../middleware/validate';\nimport { createCampaignSchema } from './campaigns.schemas';\n\nrouter.post(\n  '/campaigns',\n  authenticate,\n  validate(createCampaignSchema),\n  async (req, res) => {\n    // req.body is type-safe and validated\n  }\n);\n
"},{"location":"v2/backend/middleware/#rate-limiting","title":"Rate Limiting","text":"

rateLimit (middleware/rate-limit.ts)

Common Configurations:

// Auth endpoints: 10 requests per minute\nrateLimitRedis({\n  windowMs: 60 * 1000,\n  max: 10,\n  standardHeaders: true,\n  legacyHeaders: false,\n  keyPrefix: 'rl:auth:',\n})\n\n// Canvass visits: 30 requests per minute\nrateLimitRedis({\n  windowMs: 60 * 1000,\n  max: 30,\n  keyPrefix: 'rl:canvass-visit:',\n})\n
"},{"location":"v2/backend/middleware/#error-handling","title":"Error Handling","text":"

errorHandler (middleware/error-handler.ts)

"},{"location":"v2/backend/middleware/#request-logging","title":"Request Logging","text":"

requestLogger (middleware/logger.ts)

"},{"location":"v2/backend/middleware/#middleware-composition","title":"Middleware Composition","text":"

Middleware is applied in order and can be composed:

// Global middleware (applies to all routes)\napp.use(helmet());\napp.use(cors());\napp.use(requestLogger);\n\n// Route-specific middleware\nrouter.post(\n  '/api/campaigns',\n  rateLimit({ max: 100 }),\n  authenticate,\n  requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'),\n  validate(createCampaignSchema),\n  campaignController.create\n);\n\n// Global error handler (last middleware)\napp.use(errorHandler);\n
"},{"location":"v2/backend/middleware/#security-features","title":"Security Features","text":""},{"location":"v2/backend/middleware/#password-policy","title":"Password Policy","text":""},{"location":"v2/backend/middleware/#user-enumeration-prevention","title":"User Enumeration Prevention","text":""},{"location":"v2/backend/middleware/#refresh-token-rotation","title":"Refresh Token Rotation","text":""},{"location":"v2/backend/middleware/#redis-authentication","title":"Redis Authentication","text":""},{"location":"v2/backend/middleware/#middleware-chain","title":"Middleware Chain","text":"

Typical middleware chain for protected routes:

  1. CORS - Handle cross-origin requests
  2. Helmet - Security headers
  3. Request Logger - Log incoming request
  4. Body Parser - Parse JSON body
  5. Rate Limit - Check rate limits
  6. Authenticate - Verify JWT token
  7. Authorize - Check user role
  8. Validate - Validate request schema
  9. Route Handler - Execute business logic
  10. Error Handler - Catch and format errors
"},{"location":"v2/backend/middleware/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/backend/modules/","title":"Backend Modules","text":"

Backend modules provide feature-specific functionality for the Changemaker Lite platform. Each module follows a consistent architecture pattern with schemas, services, and routes.

"},{"location":"v2/backend/modules/#module-architecture","title":"Module Architecture","text":"

Each module typically contains:

Modules may split routes into admin and public variants (e.g., campaigns.routes.ts and campaigns-public.routes.ts).

"},{"location":"v2/backend/modules/#core-modules","title":"Core Modules","text":""},{"location":"v2/backend/modules/#authentication-user-management","title":"Authentication & User Management","text":""},{"location":"v2/backend/modules/#influence-advocacy-campaigns","title":"Influence (Advocacy Campaigns)","text":""},{"location":"v2/backend/modules/#map-location-services","title":"Map & Location Services","text":""},{"location":"v2/backend/modules/#content-management","title":"Content Management","text":""},{"location":"v2/backend/modules/#supporting-modules","title":"Supporting Modules","text":""},{"location":"v2/backend/modules/#infrastructure","title":"Infrastructure","text":""},{"location":"v2/backend/modules/#integrations","title":"Integrations","text":""},{"location":"v2/backend/modules/#email-queuing","title":"Email & Queuing","text":""},{"location":"v2/backend/modules/#geocoding-spatial","title":"Geocoding & Spatial","text":""},{"location":"v2/backend/modules/#module-list","title":"Module List","text":"Module Purpose Routes auth Authentication & sessions /api/auth/* users User management /api/users/* settings Site settings /api/settings/* campaigns Advocacy campaigns /api/campaigns/* representatives Representative lookup /api/representatives/* responses Response wall /api/responses/* locations Location database /api/locations/* cuts Geographic cuts /api/cuts/* shifts Volunteer shifts /api/shifts/* canvass Canvassing system /api/canvass/* pages Landing pages /api/pages/* media Video library /media-api/* (port 4100)"},{"location":"v2/backend/modules/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/backend/modules/auth/","title":"Auth Module","text":""},{"location":"v2/backend/modules/auth/#overview","title":"Overview","text":"

The Auth module provides JWT-based authentication with access and refresh tokens. It handles user registration, login, token refresh, and logout operations with comprehensive security features including password policy enforcement, rate limiting, and user enumeration prevention.

Key Features:

"},{"location":"v2/backend/modules/auth/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/auth/auth.routes.ts Express router with 5 endpoints api/src/modules/auth/auth.service.ts Authentication business logic api/src/modules/auth/auth.schemas.ts Zod validation schemas"},{"location":"v2/backend/modules/auth/#database-models","title":"Database Models","text":""},{"location":"v2/backend/modules/auth/#user-model","title":"User Model","text":"
model User {\n  id              String         @id @default(cuid())\n  email           String         @unique\n  password        String\n  name            String?\n  phone           String?\n  role            UserRole       @default(USER)\n  status          UserStatus     @default(ACTIVE)\n  permissions     Json?\n  createdVia      String?        @default(\"web\")\n  emailVerified   Boolean        @default(false)\n  expiresAt       DateTime?      // For TEMP users\n  lastLoginAt     DateTime?\n  createdAt       DateTime       @default(now())\n  updatedAt       DateTime       @updatedAt\n  refreshTokens   RefreshToken[]\n}\n\nenum UserRole {\n  SUPER_ADMIN\n  INFLUENCE_ADMIN\n  MAP_ADMIN\n  USER\n  TEMP\n}\n\nenum UserStatus {\n  ACTIVE\n  SUSPENDED\n  BANNED\n}\n
"},{"location":"v2/backend/modules/auth/#refreshtoken-model","title":"RefreshToken Model","text":"
model RefreshToken {\n  id        String   @id @default(cuid())\n  token     String   @unique\n  userId    String\n  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)\n  expiresAt DateTime\n  createdAt DateTime @default(now())\n}\n
"},{"location":"v2/backend/modules/auth/#api-endpoints","title":"API Endpoints","text":"Method Path Auth Rate Limit Description POST /api/auth/login None 10/min Authenticate user with email/password POST /api/auth/register None 10/min Create new user account POST /api/auth/refresh None 10/min Refresh access token POST /api/auth/logout None 10/min Invalidate refresh token GET /api/auth/me Required None Get current user profile"},{"location":"v2/backend/modules/auth/#endpoint-details","title":"Endpoint Details","text":""},{"location":"v2/backend/modules/auth/#post-apiauthlogin","title":"POST /api/auth/login","text":"

Authenticate user with email and password.

Request Body:

{\n  \"email\": \"user@example.com\",\n  \"password\": \"SecurePass123\"\n}\n

Response (200 OK):

{\n  \"user\": {\n    \"id\": \"clx1234567890\",\n    \"email\": \"user@example.com\",\n    \"name\": \"John Doe\",\n    \"phone\": null,\n    \"role\": \"USER\",\n    \"status\": \"ACTIVE\",\n    \"permissions\": null,\n    \"createdVia\": \"web\",\n    \"emailVerified\": false,\n    \"expiresAt\": null,\n    \"lastLoginAt\": \"2026-02-11T12:00:00.000Z\",\n    \"createdAt\": \"2026-02-01T12:00:00.000Z\",\n    \"updatedAt\": \"2026-02-11T12:00:00.000Z\"\n  },\n  \"accessToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n  \"refreshToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"\n}\n

Error Responses:

Implementation:

router.post(\n  '/login',\n  authRateLimit,\n  validate(loginSchema),\n  async (req: Request, res: Response, next: NextFunction) => {\n    try {\n      const result = await authService.login(req.body.email, req.body.password);\n      res.json(result);\n    } catch (err) {\n      next(err);\n    }\n  }\n);\n

Security Features:

  1. User Enumeration Prevention: Same error message for invalid email or password
  2. Account Status Validation: Checks ACTIVE, SUSPENDED, BANNED, expired states
  3. Login Metrics: Records success/failure for monitoring
  4. Last Login Tracking: Updates lastLoginAt timestamp
  5. Password Comparison: Uses bcrypt with 12 salt rounds
"},{"location":"v2/backend/modules/auth/#post-apiauthregister","title":"POST /api/auth/register","text":"

Create a new user account. Public endpoint restricted to USER role.

Request Body:

{\n  \"email\": \"newuser@example.com\",\n  \"password\": \"SecurePass123\",\n  \"name\": \"Jane Smith\",\n  \"phone\": \"+1234567890\"\n}\n

Response (201 Created):

{\n  \"user\": {\n    \"id\": \"clx0987654321\",\n    \"email\": \"newuser@example.com\",\n    \"name\": \"Jane Smith\",\n    \"phone\": \"+1234567890\",\n    \"role\": \"USER\",\n    \"status\": \"ACTIVE\",\n    \"permissions\": null,\n    \"createdVia\": \"web\",\n    \"emailVerified\": false,\n    \"expiresAt\": null,\n    \"lastLoginAt\": null,\n    \"createdAt\": \"2026-02-11T12:00:00.000Z\",\n    \"updatedAt\": \"2026-02-11T12:00:00.000Z\"\n  },\n  \"accessToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n  \"refreshToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"\n}\n

Error Responses:

Password Policy:

password: z.string()\n  .min(12, 'Password must be at least 12 characters')\n  .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')\n  .regex(/[a-z]/, 'Password must contain at least one lowercase letter')\n  .regex(/[0-9]/, 'Password must contain at least one digit')\n

Security Notes:

"},{"location":"v2/backend/modules/auth/#post-apiauthrefresh","title":"POST /api/auth/refresh","text":"

Refresh access token using a valid refresh token. Implements token rotation for security.

Request Body:

{\n  \"refreshToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"\n}\n

Response (200 OK):

{\n  \"user\": {\n    \"id\": \"clx1234567890\",\n    \"email\": \"user@example.com\",\n    \"name\": \"John Doe\",\n    \"role\": \"USER\",\n    \"status\": \"ACTIVE\",\n    ...\n  },\n  \"accessToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n  \"refreshToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"\n}\n

Error Responses:

Token Rotation Flow:

// Atomic transaction ensures no race condition\nconst tokens = await prisma.$transaction(async (tx) => {\n  // 1. Delete old refresh token\n  await tx.refreshToken.delete({ where: { id: stored.id } });\n\n  // 2. Generate new token pair\n  const accessToken = this.generateAccessToken(stored.user);\n  const refreshToken = jwt.sign(refreshPayload, env.JWT_REFRESH_SECRET, {\n    expiresIn: env.JWT_REFRESH_EXPIRY,\n  });\n\n  // 3. Store new refresh token\n  await tx.refreshToken.create({\n    data: {\n      token: refreshToken,\n      userId: stored.user.id,\n      expiresAt: new Date(decoded.exp * 1000),\n    },\n  });\n\n  return { accessToken, refreshToken };\n});\n

Security Features:

  1. Atomic Rotation: Old token deleted and new token created in single transaction
  2. Expiration Check: Validates refresh token hasn't expired
  3. Database Validation: Checks token exists in database (prevents replay attacks)
  4. Automatic Cleanup: Expired tokens deleted on access attempt
"},{"location":"v2/backend/modules/auth/#post-apiauthlogout","title":"POST /api/auth/logout","text":"

Invalidate a refresh token.

Request Body:

{\n  \"refreshToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"\n}\n

Response (200 OK):

{\n  \"message\": \"Logged out\"\n}\n

Implementation:

async logout(refreshToken: string) {\n  await prisma.refreshToken.deleteMany({ where: { token: refreshToken } });\n}\n

Notes:

"},{"location":"v2/backend/modules/auth/#get-apiauthme","title":"GET /api/auth/me","text":"

Get current authenticated user's profile.

Request Headers:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\n

Response (200 OK):

{\n  \"id\": \"clx1234567890\",\n  \"email\": \"user@example.com\",\n  \"name\": \"John Doe\",\n  \"phone\": null,\n  \"role\": \"USER\",\n  \"status\": \"ACTIVE\",\n  \"permissions\": null,\n  \"createdVia\": \"web\",\n  \"emailVerified\": false,\n  \"lastLoginAt\": \"2026-02-11T12:00:00.000Z\",\n  \"createdAt\": \"2026-02-01T12:00:00.000Z\",\n  \"updatedAt\": \"2026-02-11T12:00:00.000Z\"\n}\n

Error Responses:

Security Note:

Returns 401 instead of 404 when user not found to prevent user enumeration.

"},{"location":"v2/backend/modules/auth/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/auth/#authserviceloginemail-password","title":"authService.login(email, password)","text":"

Purpose: Authenticate user and generate token pair.

Flow:

  1. Find user by email
  2. Compare password with bcrypt
  3. Validate account status (ACTIVE, not expired)
  4. Record login metrics
  5. Update lastLoginAt timestamp
  6. Generate access + refresh token pair
  7. Return user (without password) + tokens

Error Handling:

if (!user) {\n  recordLoginAttempt('failure');\n  throw new AppError(401, 'Invalid email or password', 'INVALID_CREDENTIALS');\n}\n\nconst valid = await bcrypt.compare(password, user.password);\nif (!valid) {\n  recordLoginAttempt('failure');\n  throw new AppError(401, 'Invalid email or password', 'INVALID_CREDENTIALS');\n}\n\nif (user.status !== UserStatus.ACTIVE) {\n  recordLoginAttempt('failure');\n  throw new AppError(403, `Account is ${user.status.toLowerCase()}`, 'ACCOUNT_INACTIVE');\n}\n
"},{"location":"v2/backend/modules/auth/#authserviceregisterdata","title":"authService.register(data)","text":"

Purpose: Create new user account with hashed password.

Flow:

  1. Check if email already exists
  2. Hash password with bcrypt (12 salt rounds)
  3. Create user with USER role
  4. Generate token pair
  5. Return user (without password) + tokens

Implementation:

const hashedPassword = await bcrypt.hash(data.password, 12);\n\nconst user = await prisma.user.create({\n  data: {\n    email: data.email,\n    password: hashedPassword,\n    name: data.name,\n    phone: data.phone,\n    role: UserRole.USER, // Always USER for public registration\n  },\n});\n
"},{"location":"v2/backend/modules/auth/#authservicerefreshtokensrefreshtoken","title":"authService.refreshTokens(refreshToken)","text":"

Purpose: Rotate refresh token and issue new access token.

Security:

"},{"location":"v2/backend/modules/auth/#authservicegenerateaccesstokenuser","title":"authService.generateAccessToken(user)","text":"

Purpose: Create short-lived JWT for API authentication.

Token Payload:

interface TokenPayload {\n  id: string;\n  email: string;\n  role: UserRole;\n}\n

Configuration:

Usage:

const accessToken = authService.generateAccessToken(user);\n// Returns: \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"\n
"},{"location":"v2/backend/modules/auth/#authservicegeneraterefreshtokenuser","title":"authService.generateRefreshToken(user)","text":"

Purpose: Create long-lived JWT and store in database.

Configuration:

Implementation:

const token = jwt.sign(payload, env.JWT_REFRESH_SECRET, {\n  expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'],\n});\n\nconst decoded = jwt.decode(token) as { exp: number };\nconst expiresAt = new Date(decoded.exp * 1000);\n\nawait prisma.refreshToken.create({\n  data: {\n    token,\n    userId: user.id,\n    expiresAt,\n  },\n});\n
"},{"location":"v2/backend/modules/auth/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/auth/#complete-login-flow","title":"Complete Login Flow","text":"
// Client: Login request\nconst response = await axios.post('/api/auth/login', {\n  email: 'user@example.com',\n  password: 'SecurePass123'\n});\n\nconst { user, accessToken, refreshToken } = response.data;\n\n// Store tokens\nlocalStorage.setItem('accessToken', accessToken);\nlocalStorage.setItem('refreshToken', refreshToken);\n\n// Use access token for API requests\naxios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;\n
"},{"location":"v2/backend/modules/auth/#token-refresh-flow","title":"Token Refresh Flow","text":"
// Client: 401 interceptor for automatic token refresh\naxios.interceptors.response.use(\n  response => response,\n  async (error) => {\n    if (error.response?.status === 401 && !error.config._retry) {\n      error.config._retry = true;\n\n      const refreshToken = localStorage.getItem('refreshToken');\n      if (!refreshToken) {\n        // Redirect to login\n        window.location.href = '/login';\n        return Promise.reject(error);\n      }\n\n      try {\n        const { data } = await axios.post('/api/auth/refresh', { refreshToken });\n\n        // Update stored tokens\n        localStorage.setItem('accessToken', data.accessToken);\n        localStorage.setItem('refreshToken', data.refreshToken);\n\n        // Retry original request with new token\n        error.config.headers['Authorization'] = `Bearer ${data.accessToken}`;\n        return axios(error.config);\n      } catch (refreshError) {\n        // Refresh failed, redirect to login\n        localStorage.removeItem('accessToken');\n        localStorage.removeItem('refreshToken');\n        window.location.href = '/login';\n        return Promise.reject(refreshError);\n      }\n    }\n\n    return Promise.reject(error);\n  }\n);\n
"},{"location":"v2/backend/modules/auth/#protected-route-middleware","title":"Protected Route Middleware","text":"
// Server: Protect routes with authentication\nimport { authenticate } from '../../middleware/auth.middleware';\n\nrouter.get('/protected', authenticate, async (req, res) => {\n  // req.user is populated by authenticate middleware\n  const userId = req.user!.id;\n  const userRole = req.user!.role;\n\n  res.json({ message: 'Authenticated!', userId, userRole });\n});\n
"},{"location":"v2/backend/modules/auth/#role-based-access-control","title":"Role-Based Access Control","text":"
import { requireRole } from '../../middleware/rbac.middleware';\nimport { UserRole } from '@prisma/client';\n\n// Only SUPER_ADMIN can access\nrouter.delete('/users/:id',\n  authenticate,\n  requireRole(UserRole.SUPER_ADMIN),\n  async (req, res) => {\n    // Delete user logic\n  }\n);\n\n// Multiple roles allowed\nrouter.post('/campaigns',\n  authenticate,\n  requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN),\n  async (req, res) => {\n    // Create campaign logic\n  }\n);\n
"},{"location":"v2/backend/modules/auth/#environment-configuration","title":"Environment Configuration","text":"

Required environment variables:

# JWT Access Token (15 minutes)\nJWT_ACCESS_SECRET=<random-32-byte-hex>\nJWT_ACCESS_EXPIRY=15m\n\n# JWT Refresh Token (7 days)\nJWT_REFRESH_SECRET=<different-random-32-byte-hex>\nJWT_REFRESH_EXPIRY=7d\n\n# Database\nDATABASE_URL=postgresql://user:password@localhost:5432/changemaker_v2\n\n# Redis (for rate limiting)\nREDIS_URL=redis://:password@localhost:6379\nREDIS_PASSWORD=<redis-password>\n

Generate secrets:

# Generate random secrets (macOS/Linux)\nopenssl rand -hex 32  # For JWT_ACCESS_SECRET\nopenssl rand -hex 32  # For JWT_REFRESH_SECRET (must differ!)\n
"},{"location":"v2/backend/modules/auth/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/backend/modules/auth/#password-policy","title":"Password Policy","text":""},{"location":"v2/backend/modules/auth/#rate-limiting","title":"Rate Limiting","text":"
// 10 requests per minute per IP\nexport const authRateLimit = rateLimit({\n  windowMs: 60 * 1000,\n  max: 10,\n  message: 'Too many requests, please try again later',\n  standardHeaders: true,\n  legacyHeaders: false,\n  keyGenerator: (req) => req.ip,\n  store: new RedisStore({\n    client: redis,\n    prefix: 'rl:auth:',\n  }),\n});\n
"},{"location":"v2/backend/modules/auth/#user-enumeration-prevention","title":"User Enumeration Prevention","text":""},{"location":"v2/backend/modules/auth/#token-security","title":"Token Security","text":""},{"location":"v2/backend/modules/auth/#database-security","title":"Database Security","text":""},{"location":"v2/backend/modules/auth/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/backend/modules/campaigns/","title":"Campaigns Module","text":""},{"location":"v2/backend/modules/campaigns/#overview","title":"Overview","text":"

The Campaigns module manages advocacy email campaigns targeting elected representatives. It provides comprehensive CRUD operations with rich feature flags, automatic slug generation, and role-based visibility controls. Campaigns integrate with the representative lookup system, email sending queue, and public response wall.

Key Features:

"},{"location":"v2/backend/modules/campaigns/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/influence/campaigns/campaigns.routes.ts Admin router with 5 CRUD endpoints api/src/modules/influence/campaigns/campaigns-public.routes.ts Public router (2 endpoints, no auth) api/src/modules/influence/campaigns/campaigns.service.ts Campaign business logic api/src/modules/influence/campaigns/campaigns.schemas.ts Zod validation schemas"},{"location":"v2/backend/modules/campaigns/#database-model","title":"Database Model","text":"
model Campaign {\n  id                      String              @id @default(cuid())\n  slug                    String              @unique\n  title                   String\n  description             String?\n  emailSubject            String\n  emailBody               String\n  callToAction            String?\n  coverPhoto              String?\n  status                  CampaignStatus      @default(DRAFT)\n  targetGovernmentLevels  GovernmentLevel[]\n\n  // Feature flags\n  allowSmtpEmail          Boolean             @default(true)\n  allowMailtoLink         Boolean             @default(true)\n  collectUserInfo         Boolean             @default(true)\n  showEmailCount          Boolean             @default(true)\n  showCallCount           Boolean             @default(true)\n  allowEmailEditing       Boolean             @default(false)\n  allowCustomRecipients   Boolean             @default(false)\n  showResponseWall        Boolean             @default(false)\n  highlightCampaign       Boolean             @default(false)\n\n  // Creator tracking\n  createdByUserId         String\n  createdByUserEmail      String\n  createdByUserName       String?\n\n  // Relations\n  emails                  CampaignEmail[]\n  responses               Response[]\n  customRecipients        CustomRecipient[]\n\n  createdAt               DateTime            @default(now())\n  updatedAt               DateTime            @updatedAt\n\n  @@index([status])\n  @@index([createdByUserId])\n}\n\nenum CampaignStatus {\n  DRAFT      // Not visible to public\n  ACTIVE     // Live on public site\n  PAUSED     // Temporarily hidden\n  ARCHIVED   // Completed/historical\n}\n\nenum GovernmentLevel {\n  FEDERAL        // MPs, Prime Minister\n  PROVINCIAL     // MPPs, MLAs, Premier\n  MUNICIPAL      // Councillors, Mayor\n  SCHOOL_BOARD   // School board trustees\n}\n
"},{"location":"v2/backend/modules/campaigns/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/campaigns/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":"Method Path Auth Description GET /api/campaigns Admin roles List campaigns with pagination/filters GET /api/campaigns/:id Admin roles Get single campaign by ID POST /api/campaigns Admin roles Create new campaign PUT /api/campaigns/:id Admin roles Update campaign DELETE /api/campaigns/:id Admin roles Delete campaign

Admin Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

"},{"location":"v2/backend/modules/campaigns/#public-endpoints-no-authentication","title":"Public Endpoints (No Authentication)","text":"Method Path Auth Description GET /api/public/campaigns None List active/highlighted campaigns GET /api/public/campaigns/:slug None Get campaign by slug"},{"location":"v2/backend/modules/campaigns/#admin-endpoint-details","title":"Admin Endpoint Details","text":""},{"location":"v2/backend/modules/campaigns/#get-apicampaigns","title":"GET /api/campaigns","text":"

List campaigns with pagination, search, and filtering. Non-admin users see only their own campaigns.

Query Parameters:

Parameter Type Required Default Description page number No 1 Page number limit number No 20 Results per page (max 100) search string No - Search title or description status CampaignStatus No - Filter by status

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/campaigns?page=1&limit=10&search=climate&status=ACTIVE\"\n

Response (200 OK):

{\n  \"campaigns\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"slug\": \"climate-action-now\",\n      \"title\": \"Climate Action Now\",\n      \"description\": \"Demand bold climate policies from your representatives\",\n      \"emailSubject\": \"Pass the Climate Emergency Bill\",\n      \"emailBody\": \"Dear [Representative Name],\\n\\n...\",\n      \"callToAction\": \"Send your email now!\",\n      \"coverPhoto\": \"https://example.com/climate.jpg\",\n      \"status\": \"ACTIVE\",\n      \"targetGovernmentLevels\": [\"FEDERAL\", \"PROVINCIAL\"],\n      \"allowSmtpEmail\": true,\n      \"allowMailtoLink\": true,\n      \"collectUserInfo\": true,\n      \"showEmailCount\": true,\n      \"showCallCount\": false,\n      \"allowEmailEditing\": false,\n      \"allowCustomRecipients\": false,\n      \"showResponseWall\": true,\n      \"highlightCampaign\": true,\n      \"createdByUserId\": \"clx0987654321\",\n      \"createdByUserEmail\": \"admin@example.com\",\n      \"createdByUserName\": \"Admin User\",\n      \"createdAt\": \"2026-02-01T12:00:00.000Z\",\n      \"updatedAt\": \"2026-02-11T12:00:00.000Z\",\n      \"_count\": {\n        \"emails\": 342,\n        \"responses\": 89\n      }\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 10,\n    \"total\": 15,\n    \"totalPages\": 2\n  }\n}\n

Visibility Rules:

// Non-admin users only see their own campaigns\nconst adminRoles: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];\nif (user && !adminRoles.includes(user.role)) {\n  where.createdByUserId = user.id;\n}\n
"},{"location":"v2/backend/modules/campaigns/#post-apicampaigns","title":"POST /api/campaigns","text":"

Create new campaign with auto-generated slug.

Request Body:

{\n  \"title\": \"Climate Action Now\",\n  \"description\": \"Demand bold climate policies\",\n  \"emailSubject\": \"Pass the Climate Emergency Bill\",\n  \"emailBody\": \"Dear [Representative Name],\\n\\nI urge you to...\",\n  \"callToAction\": \"Send your email now!\",\n  \"coverPhoto\": \"https://example.com/climate.jpg\",\n  \"status\": \"DRAFT\",\n  \"targetGovernmentLevels\": [\"FEDERAL\", \"PROVINCIAL\"],\n  \"allowSmtpEmail\": true,\n  \"allowMailtoLink\": true,\n  \"showResponseWall\": true,\n  \"highlightCampaign\": true\n}\n

Response (201 Created):

Returns created campaign object (same format as GET).

Slug Generation:

function generateSlug(title: string): string {\n  return title\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, '-')  // Replace non-alphanumeric with -\n    .replace(/^-+|-+$/g, '')      // Remove leading/trailing -\n    .slice(0, 80);                // Max 80 chars\n}\n\n// Collision detection\nasync function resolveSlugCollision(slug: string, excludeId?: string): Promise<string> {\n  let candidate = slug;\n  let suffix = 2;\n\n  while (true) {\n    const existing = await prisma.campaign.findUnique({ where: { slug: candidate } });\n    if (!existing || (excludeId && existing.id === excludeId)) {\n      return candidate;\n    }\n    candidate = `${slug}-${suffix}`;  // climate-action-now-2\n    suffix++;\n  }\n}\n

Example Slug Transformations:

"},{"location":"v2/backend/modules/campaigns/#put-apicampaignsid","title":"PUT /api/campaigns/:id","text":"

Update campaign. Partial updates supported. Slug regenerated if title changes.

Request Body (Partial):

{\n  \"status\": \"ACTIVE\",\n  \"highlightCampaign\": true,\n  \"showResponseWall\": true\n}\n

Response (200 OK):

Returns updated campaign object.

"},{"location":"v2/backend/modules/campaigns/#delete-apicampaignsid","title":"DELETE /api/campaigns/:id","text":"

Delete campaign and cascade to related records.

Response (204 No Content):

No response body.

Cascading Deletes:

"},{"location":"v2/backend/modules/campaigns/#public-endpoint-details","title":"Public Endpoint Details","text":""},{"location":"v2/backend/modules/campaigns/#get-apipubliccampaigns","title":"GET /api/public/campaigns","text":"

List active and highlighted campaigns (no auth required).

Query Parameters:

Parameter Type Description highlighted boolean Filter to highlighted campaigns only limit number Results per page (max 50, default 20)

Example Request:

curl http://api.cmlite.org/api/public/campaigns?highlighted=true&limit=10\n

Response (200 OK):

{\n  \"campaigns\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"slug\": \"climate-action-now\",\n      \"title\": \"Climate Action Now\",\n      \"description\": \"Demand bold climate policies\",\n      \"callToAction\": \"Send your email now!\",\n      \"coverPhoto\": \"https://example.com/climate.jpg\",\n      \"status\": \"ACTIVE\",\n      \"highlightCampaign\": true,\n      \"showEmailCount\": true,\n      \"showCallCount\": false,\n      \"_count\": {\n        \"emails\": 342,\n        \"responses\": 89\n      }\n    }\n  ]\n}\n

Filtering:

const where: Prisma.CampaignWhereInput = {\n  status: CampaignStatus.ACTIVE,  // Only active campaigns\n};\n\nif (highlighted === 'true') {\n  where.highlightCampaign = true;\n}\n
"},{"location":"v2/backend/modules/campaigns/#get-apipubliccampaignsslug","title":"GET /api/public/campaigns/:slug","text":"

Get campaign by slug (no auth required).

Path Parameters:

Example Request:

curl http://api.cmlite.org/api/public/campaigns/climate-action-now\n

Response (200 OK):

Returns full campaign object (same as admin GET).

Error Responses:

"},{"location":"v2/backend/modules/campaigns/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/campaigns/#campaignsservicefindallfilters-user","title":"campaignsService.findAll(filters, user)","text":"

List campaigns with role-based visibility.

Visibility Logic:

// Admin users see all campaigns\n// Non-admin users see only their own campaigns\nconst adminRoles: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];\nif (user && !adminRoles.includes(user.role)) {\n  where.createdByUserId = user.id;\n}\n
"},{"location":"v2/backend/modules/campaigns/#campaignsservicecreatedata-user","title":"campaignsService.create(data, user)","text":"

Create campaign with auto-generated slug and creator tracking.

Creator Tracking:

const campaign = await prisma.campaign.create({\n  data: {\n    ...data,\n    slug: await resolveSlugCollision(generateSlug(data.title)),\n    createdByUserId: user.id,\n    createdByUserEmail: user.email,\n    createdByUserName: user.name || null,\n  },\n  select: campaignSelect,\n});\n
"},{"location":"v2/backend/modules/campaigns/#campaignsserviceupdateid-data","title":"campaignsService.update(id, data)","text":"

Update campaign. Regenerates slug if title changes.

Slug Regeneration:

if (data.title) {\n  const newSlug = generateSlug(data.title);\n  updateData.slug = await resolveSlugCollision(newSlug, id);\n}\n
"},{"location":"v2/backend/modules/campaigns/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/campaigns/#create-campaign-schema","title":"Create Campaign Schema","text":"
export const createCampaignSchema = z.object({\n  title: z.string().min(1, 'Title is required'),\n  description: z.string().optional(),\n  emailSubject: z.string().min(1, 'Email subject is required'),\n  emailBody: z.string().min(1, 'Email body is required'),\n  callToAction: z.string().optional(),\n  status: z.nativeEnum(CampaignStatus).optional().default(CampaignStatus.DRAFT),\n  targetGovernmentLevels: z.array(z.nativeEnum(GovernmentLevel)).optional().default([]),\n  allowSmtpEmail: z.boolean().optional().default(true),\n  allowMailtoLink: z.boolean().optional().default(true),\n  collectUserInfo: z.boolean().optional().default(true),\n  showEmailCount: z.boolean().optional().default(true),\n  showCallCount: z.boolean().optional().default(true),\n  allowEmailEditing: z.boolean().optional().default(false),\n  allowCustomRecipients: z.boolean().optional().default(false),\n  showResponseWall: z.boolean().optional().default(false),\n  highlightCampaign: z.boolean().optional().default(false),\n  coverPhoto: z.string().optional(),\n});\n
"},{"location":"v2/backend/modules/campaigns/#feature-flags","title":"Feature Flags","text":"Flag Default Description allowSmtpEmail true Enable direct SMTP email sending via queue allowMailtoLink true Show mailto: link option (opens default email client) collectUserInfo true Collect sender name, email, postal code showEmailCount true Display email send count on public page showCallCount true Display call count (future feature) allowEmailEditing false Let users edit email template before sending allowCustomRecipients false Allow manual recipient selection (overrides postal code lookup) showResponseWall false Enable public response submission + display highlightCampaign false Featured campaign (shown on homepage)"},{"location":"v2/backend/modules/campaigns/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/campaigns/#admin-create-campaign","title":"Admin: Create Campaign","text":"
import { api } from '@/lib/api';\n\nconst createCampaign = async () => {\n  const { data } = await api.post('/api/campaigns', {\n    title: 'Climate Action Now',\n    emailSubject: 'Pass the Climate Emergency Bill',\n    emailBody: 'Dear [Representative Name],\\n\\nI urge you to support immediate climate action...',\n    targetGovernmentLevels: ['FEDERAL', 'PROVINCIAL'],\n    status: 'DRAFT',\n    showResponseWall: true,\n    highlightCampaign: true,\n  });\n\n  console.log(`Campaign created: ${data.slug}`);\n  return data;\n};\n
"},{"location":"v2/backend/modules/campaigns/#public-list-active-campaigns","title":"Public: List Active Campaigns","text":"
import axios from 'axios';\n\nconst fetchActiveCampaigns = async () => {\n  const { data } = await axios.get('/api/public/campaigns?highlighted=true');\n  return data.campaigns;\n};\n
"},{"location":"v2/backend/modules/campaigns/#admin-update-campaign-status","title":"Admin: Update Campaign Status","text":"
import { api } from '@/lib/api';\n\nconst publishCampaign = async (id: string) => {\n  const { data } = await api.put(`/api/campaigns/${id}`, {\n    status: 'ACTIVE',\n  });\n\n  message.success('Campaign published!');\n  return data;\n};\n
"},{"location":"v2/backend/modules/campaigns/#frontend-integration","title":"Frontend Integration","text":"

The CampaignsPage component (admin/src/pages/CampaignsPage.tsx) provides:

State Management:

const [campaigns, setCampaigns] = useState<Campaign[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });\nconst [filters, setFilters] = useState({ search: '', status: null });\n
"},{"location":"v2/backend/modules/campaigns/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/backend/modules/canvass/","title":"Canvass Module","text":""},{"location":"v2/backend/modules/canvass/#overview","title":"Overview","text":"

The Canvass module powers the volunteer canvassing system, enabling door-to-door outreach with GPS tracking, visit recording, walking route optimization, and real-time progress monitoring. It features role-based permissions, automated session management, and comprehensive analytics for campaign organizers.

Key Features:

"},{"location":"v2/backend/modules/canvass/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/map/canvass/canvass.routes.ts 2 routers (volunteer + admin) with 22 endpoints api/src/modules/map/canvass/canvass.service.ts Canvass business logic + session management api/src/modules/map/canvass/canvass.schemas.ts Zod validation schemas api/src/modules/map/canvass/canvass-route.service.ts Walking route optimization algorithm"},{"location":"v2/backend/modules/canvass/#database-models","title":"Database Models","text":""},{"location":"v2/backend/modules/canvass/#canvasssession","title":"CanvassSession","text":"
model CanvassSession {\n  id             String                @id @default(cuid())\n  userId         String\n  user           User                  @relation(fields: [userId], references: [id], onDelete: Cascade)\n  cutId          String\n  cut            Cut                   @relation(fields: [cutId], references: [id], onDelete: Cascade)\n  shiftId        String?\n  shift          Shift?                @relation(fields: [shiftId], references: [id], onDelete: SetNull)\n  status         CanvassSessionStatus  @default(ACTIVE)\n  startLatitude  Float?\n  startLongitude Float?\n  startedAt      DateTime              @default(now())\n  endedAt        DateTime?\n  visits         CanvassVisit[]\n\n  @@index([userId])\n  @@index([cutId])\n  @@index([status])\n  @@map(\"canvass_sessions\")\n}\n\nenum CanvassSessionStatus {\n  ACTIVE     // Currently canvassing\n  COMPLETED  // Ended by volunteer\n  ABANDONED  // Auto-closed after 12h\n}\n
"},{"location":"v2/backend/modules/canvass/#canvassvisit","title":"CanvassVisit","text":"
model CanvassVisit {\n  id              String         @id @default(cuid())\n  addressId       String         // Changed from locationId to support multi-unit buildings\n  address         Address        @relation(fields: [addressId], references: [id], onDelete: Cascade)\n  userId          String\n  user            User           @relation(fields: [userId], references: [id], onDelete: Cascade)\n  sessionId       String?\n  session         CanvassSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)\n  shiftId         String?\n  shift           Shift?         @relation(fields: [shiftId], references: [id], onDelete: SetNull)\n  outcome         VisitOutcome\n  supportLevel    SupportLevel?\n  signRequested   Boolean        @default(false)\n  signSize        String?\n  notes           String?        @db.Text\n  durationSeconds Int?\n  visitedAt       DateTime       @default(now())\n\n  @@index([addressId])\n  @@index([userId])\n  @@index([sessionId])\n  @@index([outcome])\n  @@map(\"canvass_visits\")\n}\n\nenum VisitOutcome {\n  CONTACTED      // Successful conversation\n  SUPPORTER      // Supporter identified\n  NOT_HOME       // No answer\n  REFUSED        // Declined conversation\n  MOVED          // No longer at address\n  WRONG_ADDRESS  // Address doesn't exist\n  CALLBACK       // Requested follow-up\n  INACCESSIBLE   // Cannot access (locked building, no entry)\n}\n
"},{"location":"v2/backend/modules/canvass/#address-model-multi-unit-support","title":"Address Model (Multi-Unit Support)","text":"
model Address {\n  id           String        @id @default(cuid())\n  locationId   String\n  location     Location      @relation(fields: [locationId], references: [id], onDelete: Cascade)\n  unitNumber   String?\n  firstName    String?\n  lastName     String?\n  email        String?\n  phone        String?\n  supportLevel SupportLevel?\n  sign         Boolean       @default(false)\n  signSize     String?\n  notes        String?       @db.Text\n  visits       CanvassVisit[]\n\n  @@index([locationId])\n  @@map(\"addresses\")\n}\n

Multi-Unit Building Support:

"},{"location":"v2/backend/modules/canvass/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/canvass/#volunteer-endpoints-authentication-required-any-role","title":"Volunteer Endpoints (Authentication Required, Any Role)","text":"Method Path Description GET /api/map/canvass/my/assignments Get assigned shifts with cuts GET /api/map/canvass/my/stats Get volunteer statistics GET /api/map/canvass/my/visits List my visit history (paginated) GET /api/map/canvass/my/session Get active canvass session POST /api/map/canvass/sessions Start new canvass session POST /api/map/canvass/sessions/:id/end End canvass session GET /api/map/canvass/cuts/:cutId/locations Get locations in cut for canvassing GET /api/map/canvass/cuts/:cutId/route Get optimized walking route GET /api/map/canvass/locations Get all locations with visit annotations PUT /api/map/canvass/locations/:id Update location (role-gated fields) POST /api/map/canvass/locations Create location (role-gated fields) POST /api/map/canvass/reverse-geocode Reverse geocode lat/lng POST /api/map/canvass/geocode-search Geocode address for map search POST /api/map/canvass/visits Record visit (rate-limited: 30/min) POST /api/map/canvass/visits/bulk Bulk record visits for building (rate-limited: 10/min)"},{"location":"v2/backend/modules/canvass/#admin-endpoints-authentication-required-map_admin-roles","title":"Admin Endpoints (Authentication Required, MAP_ADMIN Roles)","text":"Method Path Description GET /api/map/canvass/stats Get admin statistics GET /api/map/canvass/stats/cuts/:cutId Get cut-specific statistics GET /api/map/canvass/activity Get recent activity feed (paginated) GET /api/map/canvass/volunteers List volunteers with visit counts GET /api/map/canvass/volunteers/:userId Get volunteer statistics GET /api/map/canvass/visits List all visits (paginated, filtered)

Admin Roles: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/backend/modules/canvass/#volunteer-endpoint-details","title":"Volunteer Endpoint Details","text":""},{"location":"v2/backend/modules/canvass/#post-apimapcanvasssessions","title":"POST /api/map/canvass/sessions","text":"

Start new canvass session for a cut.

Request Body:

{\n  \"cutId\": \"clxCut123\",\n  \"shiftId\": \"clxShift456\",\n  \"startLatitude\": 43.6532,\n  \"startLongitude\": -79.3832\n}\n

Response (201 Created):

{\n  \"id\": \"clxSession789\",\n  \"userId\": \"clxUser123\",\n  \"cutId\": \"clxCut123\",\n  \"shiftId\": \"clxShift456\",\n  \"status\": \"ACTIVE\",\n  \"startLatitude\": 43.6532,\n  \"startLongitude\": -79.3832,\n  \"startedAt\": \"2026-02-11T14:00:00.000Z\",\n  \"endedAt\": null,\n  \"cut\": {\n    \"id\": \"clxCut123\",\n    \"name\": \"Downtown Ward 5\"\n  },\n  \"shift\": {\n    \"id\": \"clxShift456\",\n    \"title\": \"Saturday Canvass\"\n  }\n}\n

Validation:

Error Responses:

"},{"location":"v2/backend/modules/canvass/#post-apimapcanvasssessionsidend","title":"POST /api/map/canvass/sessions/:id/end","text":"

End active canvass session.

Path Parameters:

Response (200 OK):

Returns updated session with status: COMPLETED and endedAt timestamp.

Post-Processing:

Validation:

"},{"location":"v2/backend/modules/canvass/#get-apimapcanvassmyassignments","title":"GET /api/map/canvass/my/assignments","text":"

Get volunteer's assigned shifts with associated cuts.

Example Response (200 OK):

[\n  {\n    \"shiftId\": \"clxShift456\",\n    \"shiftTitle\": \"Saturday Canvass\",\n    \"shiftDate\": \"2026-02-15\",\n    \"startTime\": \"10:00\",\n    \"endTime\": \"14:00\",\n    \"location\": \"Community Center, 123 Main St\",\n    \"cutId\": \"clxCut123\",\n    \"cutName\": \"Downtown Ward 5\",\n    \"completionPercentage\": 42\n  }\n]\n

Filtering:

"},{"location":"v2/backend/modules/canvass/#get-apimapcanvasscutscutidlocations","title":"GET /api/map/canvass/cuts/:cutId/locations","text":"

Get locations within cut for canvassing with visit annotations.

Path Parameters:

Query Parameters:

Example Response (200 OK):

[\n  {\n    \"id\": \"clxAddress123\",\n    \"unitNumber\": \"Apt 4\",\n    \"firstName\": \"John\",\n    \"lastName\": \"Doe\",\n    \"email\": \"john@example.com\",\n    \"phone\": \"416-555-1234\",\n    \"supportLevel\": \"LEVEL_1\",\n    \"sign\": true,\n    \"signSize\": \"Large\",\n    \"notes\": \"Willing to volunteer\",\n    \"location\": {\n      \"id\": \"clxLocation456\",\n      \"latitude\": 43.6532,\n      \"longitude\": -79.3832,\n      \"address\": \"123 Main St, Toronto, ON\",\n      \"buildingNotes\": \"Intercom code: 1234\"\n    },\n    \"lastVisit\": {\n      \"outcome\": \"CONTACTED\",\n      \"visitedAt\": \"2026-02-10T14:30:00.000Z\",\n      \"visitorName\": \"Jane Smith\",\n      \"isMyVisit\": false\n    }\n  }\n]\n

Two-Stage Filtering:

  1. Database bounds filter \u2014 Fast WHERE clause on lat/lng
  2. Polygon filter \u2014 In-memory point-in-polygon check

Visit Annotations:

"},{"location":"v2/backend/modules/canvass/#get-apimapcanvasscutscutidroute","title":"GET /api/map/canvass/cuts/:cutId/route","text":"

Get optimized walking route for cut.

Path Parameters:

Query Parameters:

Example Response (200 OK):

{\n  \"route\": [\n    {\n      \"id\": \"clxAddress123\",\n      \"latitude\": 43.6532,\n      \"longitude\": -79.3832,\n      \"address\": \"123 Main St\",\n      \"unitNumber\": \"Apt 4\",\n      \"distanceFromPrevious\": 0\n    },\n    {\n      \"id\": \"clxAddress124\",\n      \"latitude\": 43.6540,\n      \"longitude\": -79.3825,\n      \"address\": \"125 Main St\",\n      \"unitNumber\": null,\n      \"distanceFromPrevious\": 92.3\n    }\n  ],\n  \"totalDistance\": 1847.6,\n  \"estimatedDuration\": 1680\n}\n

Walking Route Algorithm:

Nearest-neighbor greedy algorithm:

// Start at provided coordinates or first location\nlet current = startCoords || locations[0];\nconst route: RouteStop[] = [];\n\nwhile (unvisited.length > 0) {\n  // Find nearest unvisited location\n  const nearest = findNearest(current, unvisited);\n  const distance = haversineDistance(current, nearest);\n\n  route.push({\n    ...nearest,\n    distanceFromPrevious: distance,\n  });\n\n  current = nearest;\n  unvisited = unvisited.filter(loc => loc.id !== nearest.id);\n}\n\n// Calculate total distance and duration\nconst totalDistance = route.reduce((sum, stop) => sum + stop.distanceFromPrevious, 0);\nconst estimatedDuration = Math.ceil(totalDistance / WALKING_SPEED_MPS); // 1.4 m/s\n

Performance:

"},{"location":"v2/backend/modules/canvass/#post-apimapcanvassvisits","title":"POST /api/map/canvass/visits","text":"

Record visit to an address.

Rate Limiting: 30 requests per minute per IP

Request Body:

{\n  \"addressId\": \"clxAddress123\",\n  \"outcome\": \"CONTACTED\",\n  \"supportLevel\": \"LEVEL_2\",\n  \"signRequested\": true,\n  \"signSize\": \"Large\",\n  \"notes\": \"Interested in volunteering for phone banks\",\n  \"durationSeconds\": 180,\n  \"sessionId\": \"clxSession789\",\n  \"shiftId\": \"clxShift456\",\n  \"updateLocation\": true\n}\n

Field Descriptions:

Response (201 Created):

Returns created visit object.

Address Update Logic:

If updateLocation=true and outcome is CONTACTED or SUPPORTER:

await prisma.address.update({\n  where: { id: addressId },\n  data: {\n    supportLevel: data.supportLevel || undefined,\n    sign: data.signRequested || undefined,\n    signSize: data.signRequested ? data.signSize : undefined,\n  },\n});\n

Metrics:

"},{"location":"v2/backend/modules/canvass/#post-apimapcanvassvisitsbulk","title":"POST /api/map/canvass/visits/bulk","text":"

Record visit to all unvisited units in a building.

Rate Limiting: 10 requests per minute per IP (stricter than single visits)

Request Body:

{\n  \"locationId\": \"clxLocation456\",\n  \"outcome\": \"NOT_HOME\",\n  \"notes\": \"Building-wide: No answer at any unit\",\n  \"sessionId\": \"clxSession789\",\n  \"shiftId\": \"clxShift456\"\n}\n

Allowed Outcomes:

Only non-contact outcomes: - NOT_HOME - REFUSED - MOVED

Logic:

  1. Find all addresses at location (building)
  2. Filter to unvisited addresses (no existing visit records)
  3. Create visit records for all unvisited addresses in bulk

Response (201 Created):

{\n  \"created\": 8,\n  \"skipped\": 2,\n  \"locationId\": \"clxLocation456\"\n}\n

Use Cases:

"},{"location":"v2/backend/modules/canvass/#put-apimapcanvasslocationsid","title":"PUT /api/map/canvass/locations/:id","text":"

Update location with role-gated field restrictions.

Path Parameters:

Request Body (Volunteer):

{\n  \"supportLevel\": \"LEVEL_2\",\n  \"sign\": true,\n  \"signSize\": \"Large\",\n  \"notes\": \"Willing to volunteer\"\n}\n

Request Body (Admin):

{\n  \"firstName\": \"John\",\n  \"lastName\": \"Doe\",\n  \"address\": \"123 Main St, Unit 4\",\n  \"unitNumber\": \"4\",\n  \"email\": \"john@example.com\",\n  \"phone\": \"416-555-1234\",\n  \"supportLevel\": \"LEVEL_2\",\n  \"sign\": true\n}\n

Role-Gated Fields:

All Authenticated Users: - supportLevel - sign - signSize - notes

Admins Only (SUPER_ADMIN, MAP_ADMIN): - firstName - lastName - address - unitNumber - email - phone

TEMP Users:

Service-Level Field Stripping:

const isAdmin = role === UserRole.SUPER_ADMIN || role === UserRole.MAP_ADMIN;\nconst isTemp = role === UserRole.TEMP;\n\nif (isTemp) {\n  throw new AppError(403, 'TEMP users cannot edit locations', 'FORBIDDEN');\n}\n\nconst updateData: Prisma.AddressUpdateInput = {};\n\n// Volunteer fields (all authenticated users)\nif (data.supportLevel !== undefined) updateData.supportLevel = data.supportLevel;\nif (data.sign !== undefined) updateData.sign = data.sign;\nif (data.signSize !== undefined) updateData.signSize = data.signSize;\nif (data.notes !== undefined) updateData.notes = data.notes;\n\n// Admin-only PII fields\nif (isAdmin) {\n  if (data.firstName !== undefined) updateData.firstName = data.firstName;\n  if (data.lastName !== undefined) updateData.lastName = data.lastName;\n  if (data.email !== undefined) updateData.email = data.email;\n  if (data.phone !== undefined) updateData.phone = data.phone;\n}\n
"},{"location":"v2/backend/modules/canvass/#admin-endpoint-details","title":"Admin Endpoint Details","text":""},{"location":"v2/backend/modules/canvass/#get-apimapcanvassstats","title":"GET /api/map/canvass/stats","text":"

Get aggregate canvassing statistics.

Example Response (200 OK):

{\n  \"totalVisits\": 3847,\n  \"totalVolunteers\": 42,\n  \"activeSessions\": 7,\n  \"byOutcome\": {\n    \"CONTACTED\": 1892,\n    \"SUPPORTER\": 542,\n    \"NOT_HOME\": 987,\n    \"REFUSED\": 234,\n    \"MOVED\": 89,\n    \"WRONG_ADDRESS\": 43,\n    \"CALLBACK\": 34,\n    \"INACCESSIBLE\": 26\n  },\n  \"topVolunteers\": [\n    {\n      \"userId\": \"clxUser123\",\n      \"name\": \"Jane Smith\",\n      \"visitCount\": 247\n    }\n  ],\n  \"cutProgress\": [\n    {\n      \"cutId\": \"clxCut123\",\n      \"cutName\": \"Downtown Ward 5\",\n      \"completionPercentage\": 68,\n      \"visitCount\": 342,\n      \"totalAddresses\": 503\n    }\n  ]\n}\n
"},{"location":"v2/backend/modules/canvass/#get-apimapcanvassactivity","title":"GET /api/map/canvass/activity","text":"

Get recent canvass activity feed.

Query Parameters:

Example Response (200 OK):

{\n  \"activities\": [\n    {\n      \"id\": \"clxVisit789\",\n      \"userId\": \"clxUser123\",\n      \"user\": {\n        \"name\": \"Jane Smith\",\n        \"email\": \"jane@example.com\"\n      },\n      \"addressId\": \"clxAddress456\",\n      \"address\": {\n        \"address\": \"123 Main St\",\n        \"unitNumber\": \"Apt 4\"\n      },\n      \"outcome\": \"CONTACTED\",\n      \"supportLevel\": \"LEVEL_2\",\n      \"visitedAt\": \"2026-02-11T14:30:00.000Z\",\n      \"durationSeconds\": 180\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 3847,\n    \"totalPages\": 193\n  }\n}\n
"},{"location":"v2/backend/modules/canvass/#get-apimapcanvassvolunteers","title":"GET /api/map/canvass/volunteers","text":"

List volunteers with visit counts.

Example Response (200 OK):

[\n  {\n    \"userId\": \"clxUser123\",\n    \"name\": \"Jane Smith\",\n    \"email\": \"jane@example.com\",\n    \"totalVisits\": 247,\n    \"todayVisits\": 18,\n    \"activeSessions\": 1\n  }\n]\n
"},{"location":"v2/backend/modules/canvass/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/canvass/#canvassservicestartsessionuserid-data","title":"canvassService.startSession(userId, data)","text":"

Start new canvass session.

Validation:

// Check for existing active session\nconst existing = await prisma.canvassSession.findFirst({\n  where: { userId, status: CanvassSessionStatus.ACTIVE },\n});\nif (existing) {\n  throw new AppError(409, 'You already have an active canvass session', 'SESSION_ACTIVE');\n}\n\n// Verify cut exists\nconst cut = await prisma.cut.findUnique({ where: { id: data.cutId } });\nif (!cut) {\n  throw new AppError(404, 'Cut not found', 'CUT_NOT_FOUND');\n}\n
"},{"location":"v2/backend/modules/canvass/#canvassserviceendsessionsessionid-userid","title":"canvassService.endSession(sessionId, userId)","text":"

End canvass session and recalculate cut completion.

Post-Processing:

// End session\nawait prisma.canvassSession.update({\n  where: { id: sessionId },\n  data: { status: CanvassSessionStatus.COMPLETED, endedAt: new Date() },\n});\n\n// Recalculate cut completion percentage\nawait this.recalculateCutCompletion(session.cutId);\n

Cut Completion Calculation:

async recalculateCutCompletion(cutId: string) {\n  // Get all addresses in cut\n  const totalAddresses = await this.countAddressesInCut(cutId);\n\n  // Get visited addresses (distinct addressId from visits)\n  const visitedCount = await prisma.canvassVisit.findMany({\n    where: { address: { location: { cuts: { some: { id: cutId } } } } },\n    distinct: ['addressId'],\n  }).then(visits => visits.length);\n\n  const completionPercentage = totalAddresses > 0\n    ? Math.round((visitedCount / totalAddresses) * 100)\n    : 0;\n\n  await prisma.cut.update({\n    where: { id: cutId },\n    data: { completionPercentage },\n  });\n}\n
"},{"location":"v2/backend/modules/canvass/#canvassservicerecordvisituserid-data","title":"canvassService.recordVisit(userId, data)","text":"

Record visit to address with optional location update.

Address Update Logic:

if (data.updateLocation && (data.outcome === VisitOutcome.CONTACTED || data.outcome === VisitOutcome.SUPPORTER)) {\n  await prisma.address.update({\n    where: { id: data.addressId },\n    data: {\n      supportLevel: data.supportLevel || undefined,\n      sign: data.signRequested || undefined,\n      signSize: data.signRequested ? data.signSize : undefined,\n    },\n  });\n}\n

Metrics:

recordCanvassVisit(data.outcome); // Prometheus counter\n
"},{"location":"v2/backend/modules/canvass/#canvassservicegetwalkingroutecutid-userid-options","title":"canvassService.getWalkingRoute(cutId, userId, options)","text":"

Get optimized walking route for cut.

Algorithm:

import { calculateWalkingRoute } from './canvass-route.service';\n\nconst addresses = await this.getCutLocationsForCanvass(cutId, userId);\n\n// Filter to unvisited if requested\nconst unvisited = options.excludeVisited\n  ? addresses.filter(addr => !addr.lastVisit)\n  : addresses;\n\n// Calculate route using nearest-neighbor algorithm\nconst route = calculateWalkingRoute(\n  unvisited,\n  options.startLatitude,\n  options.startLongitude,\n);\n\nreturn route;\n
"},{"location":"v2/backend/modules/canvass/#abandoned-session-cleanup","title":"Abandoned Session Cleanup","text":"

Scheduled Task:

Runs on API startup and every hour:

// api/src/server.ts\nasync function closeAbandonedSessions() {\n  const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);\n\n  const result = await prisma.canvassSession.updateMany({\n    where: {\n      status: CanvassSessionStatus.ACTIVE,\n      startedAt: { lt: twelveHoursAgo },\n    },\n    data: {\n      status: CanvassSessionStatus.ABANDONED,\n      endedAt: new Date(),\n    },\n  });\n\n  if (result.count > 0) {\n    logger.info(`Closed ${result.count} abandoned canvass sessions`);\n  }\n}\n\n// Run on startup\ncloseAbandonedSessions();\n\n// Run every hour\nsetInterval(closeAbandonedSessions, 60 * 60 * 1000);\n
"},{"location":"v2/backend/modules/canvass/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/canvass/#record-visit-schema","title":"Record Visit Schema","text":"
export const recordVisitSchema = z.object({\n  addressId: z.string().min(1),\n  outcome: z.nativeEnum(VisitOutcome),\n  supportLevel: z.nativeEnum(SupportLevel).optional(),\n  signRequested: z.boolean().optional().default(false),\n  signSize: z.string().optional(),\n  notes: z.string().optional(),\n  durationSeconds: z.number().int().optional(),\n  sessionId: z.string().optional(),\n  shiftId: z.string().optional(),\n  updateLocation: z.boolean().optional().default(true),\n});\n
"},{"location":"v2/backend/modules/canvass/#bulk-record-visit-schema","title":"Bulk Record Visit Schema","text":"
export const bulkRecordVisitSchema = z.object({\n  locationId: z.string().min(1), // Building ID\n  outcome: z.enum(['NOT_HOME', 'REFUSED', 'MOVED']), // Only non-contact outcomes\n  notes: z.string().optional(),\n  sessionId: z.string().optional(),\n  shiftId: z.string().optional(),\n});\n
"},{"location":"v2/backend/modules/canvass/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/canvass/#volunteer-start-canvass-session","title":"Volunteer: Start Canvass Session","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst startSession = async (cutId: string, shiftId?: string) => {\n  // Get current GPS position\n  navigator.geolocation.getCurrentPosition(async (position) => {\n    try {\n      const { data } = await api.post('/api/map/canvass/sessions', {\n        cutId,\n        shiftId,\n        startLatitude: position.coords.latitude,\n        startLongitude: position.coords.longitude,\n      });\n\n      message.success('Canvass session started');\n      console.log(`Session ID: ${data.id}`);\n    } catch (error: any) {\n      if (error.response?.status === 409) {\n        message.error('You already have an active session');\n      } else {\n        message.error('Failed to start session');\n      }\n    }\n  });\n};\n
"},{"location":"v2/backend/modules/canvass/#volunteer-record-visit","title":"Volunteer: Record Visit","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst recordVisit = async (addressId: string, outcome: string, sessionId: string) => {\n  try {\n    const { data } = await api.post('/api/map/canvass/visits', {\n      addressId,\n      outcome,\n      supportLevel: 'LEVEL_2',\n      signRequested: true,\n      signSize: 'Large',\n      notes: 'Interested in volunteering',\n      durationSeconds: 180,\n      sessionId,\n      updateLocation: true,\n    });\n\n    message.success('Visit recorded');\n    return data;\n  } catch (error: any) {\n    if (error.response?.status === 429) {\n      message.error('Rate limit exceeded. Please wait a moment.');\n    } else {\n      message.error('Failed to record visit');\n    }\n  }\n};\n
"},{"location":"v2/backend/modules/canvass/#admin-get-canvass-statistics","title":"Admin: Get Canvass Statistics","text":"
import { api } from '@/lib/api';\n\nconst getStats = async () => {\n  const { data } = await api.get('/api/map/canvass/stats');\n\n  console.log(`Total Visits: ${data.totalVisits}`);\n  console.log(`Active Sessions: ${data.activeSessions}`);\n  console.log(`Top Volunteer: ${data.topVolunteers[0]?.name} (${data.topVolunteers[0]?.visitCount} visits)`);\n\n  return data;\n};\n
"},{"location":"v2/backend/modules/canvass/#frontend-integration","title":"Frontend Integration","text":""},{"location":"v2/backend/modules/canvass/#volunteer-portal","title":"Volunteer Portal","text":"

VolunteerMapPage (admin/src/pages/volunteer/VolunteerMapPage.tsx):

MyAssignmentsPage (admin/src/pages/volunteer/MyAssignmentsPage.tsx):

MyActivityPage (admin/src/pages/volunteer/MyActivityPage.tsx):

State Management:

// admin/src/stores/canvass.store.ts\ninterface CanvassState {\n  session: CanvassSession | null;\n  locations: CanvassLocation[];\n  route: WalkingRoute | null;\n  gpsPosition: { lat: number; lng: number } | null;\n  selectedAddress: string | null;\n  showVisitRecording: boolean;\n}\n
"},{"location":"v2/backend/modules/canvass/#admin-dashboard","title":"Admin Dashboard","text":"

CanvassDashboardPage (admin/src/pages/CanvassDashboardPage.tsx):

"},{"location":"v2/backend/modules/canvass/#performance-considerations","title":"Performance Considerations","text":"

Rate Limiting:

Abandoned Session Cleanup:

Walking Route Algorithm:

Cut Completion Calculation:

"},{"location":"v2/backend/modules/canvass/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/canvass/#issue-you-already-have-an-active-canvass-session","title":"Issue: \"You already have an active canvass session\"","text":"

Cause: Volunteer forgot to end previous session

Solution:

"},{"location":"v2/backend/modules/canvass/#issue-rate-limit-exceeded-429-when-recording-visits","title":"Issue: Rate limit exceeded (429) when recording visits","text":"

Cause: Recording visits too quickly (>30/min)

Solution:

"},{"location":"v2/backend/modules/canvass/#issue-walking-route-skips-some-addresses","title":"Issue: Walking route skips some addresses","text":"

Cause: excludeVisited=true filters out already-visited addresses

Solution:

"},{"location":"v2/backend/modules/canvass/#issue-cut-completion-percentage-not-updating","title":"Issue: Cut completion percentage not updating","text":"

Cause: Completion calculated on session end, not per-visit

Solution:

"},{"location":"v2/backend/modules/canvass/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/backend/modules/locations/","title":"Locations Module","text":""},{"location":"v2/backend/modules/locations/#overview","title":"Overview","text":"

The Locations module manages geographic locations for organizing campaigns, mapping volunteers, and tracking supporter data. It features multi-provider geocoding, NAR (National Address Register) bulk import with 2025 format support, CSV import/export, location history tracking, and comprehensive filtering with spatial queries.

Key Features:

"},{"location":"v2/backend/modules/locations/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/map/locations/locations.routes.ts 2 routers (admin + public) with 20 endpoints api/src/modules/map/locations/locations.service.ts Location business logic + geocoding + NAR import (1,100 lines) api/src/modules/map/locations/locations.schemas.ts Zod validation schemas api/src/modules/map/locations/nar-import.service.ts NAR import service (server-side streaming, legacy support) api/src/modules/map/locations/nar-import.routes.ts NAR import admin routes api/src/modules/map/locations/bulk-geocode.routes.ts Bulk geocoding queue routes api/src/modules/map/locations/bulk-geocode.schemas.ts Bulk geocoding schemas"},{"location":"v2/backend/modules/locations/#database-models","title":"Database Models","text":""},{"location":"v2/backend/modules/locations/#location","title":"Location","text":"
model Location {\n  id                  String         @id @default(cuid())\n  address             String\n  unitNumber          String?\n  firstName           String?\n  lastName            String?\n  email               String?\n  phone               String?\n  supportLevel        SupportLevel?\n  sign                Boolean        @default(false)\n  signSize            String?\n  notes               String?        @db.Text\n  buildingNotes       String?        @db.Text\n\n  // Geocoding\n  latitude            Float?\n  longitude           Float?\n  geocodeConfidence   Int?\n  geocodeProvider     GeocodeProvider?\n\n  // NAR fields (2025 format support)\n  postalCode          String?\n  province            String?\n  federalDistrict     String?\n  buildingUse         Int?           // 1=Residential, 2=Commercial, 3=Mixed\n\n  // Audit\n  createdByUserId     String?\n  updatedByUserId     String?\n  createdAt           DateTime       @default(now())\n  updatedAt           DateTime       @updatedAt\n\n  // Relations\n  createdByUser       User?          @relation(\"LocationCreator\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n  updatedByUser       User?          @relation(\"LocationUpdater\", fields: [updatedByUserId], references: [id], onDelete: SetNull)\n  history             LocationHistory[]\n\n  @@index([latitude, longitude])\n  @@index([supportLevel])\n  @@index([sign])\n  @@index([geocodeConfidence])\n  @@map(\"locations\")\n}\n\nenum SupportLevel {\n  LEVEL_1  // Strong support\n  LEVEL_2  // Moderate support\n  LEVEL_3  // Undecided\n  LEVEL_4  // Opposed\n}\n\nenum GeocodeProvider {\n  NOMINATIM\n  MAPBOX\n  ARCGIS\n  PHOTON\n  GOOGLE\n  LOCATIONIQ\n  UNKNOWN\n}\n
"},{"location":"v2/backend/modules/locations/#locationhistory","title":"LocationHistory","text":"
model LocationHistory {\n  id         String                @id @default(cuid())\n  locationId String\n  location   Location              @relation(fields: [locationId], references: [id], onDelete: Cascade)\n  userId     String?\n  user       User?                 @relation(fields: [userId], references: [id], onDelete: SetNull)\n  action     LocationHistoryAction\n  field      String?\n  oldValue   String?\n  newValue   String?\n  metadata   Json?\n  createdAt  DateTime              @default(now())\n\n  @@index([locationId])\n  @@index([userId])\n  @@index([action])\n  @@map(\"location_history\")\n}\n\nenum LocationHistoryAction {\n  CREATED\n  UPDATED\n  GEOCODED\n  MOVED_ON_MAP\n  DELETED\n}\n

History Tracking:

"},{"location":"v2/backend/modules/locations/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/locations/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":"Method Path Description GET /api/map/locations List locations (paginated, filtered) GET /api/map/locations/stats Location statistics GET /api/map/locations/export-csv Export CSV download GET /api/map/locations/all All geocoded locations for map (admin, 5000 limit) GET /api/map/locations/:id Get single location GET /api/map/locations/:id/history Get location edit history POST /api/map/locations Create location (auto-geocodes if no lat/lng) POST /api/map/locations/geocode Geocode single address POST /api/map/locations/geocode-missing Geocode all ungeocoded locations POST /api/map/locations/import-csv Upload + import CSV (10MB limit) POST /api/map/locations/import-bulk Bulk import NAR or CSV (100MB limit, 5min timeout) POST /api/map/locations/reverse-geocode Reverse geocode lat/lng to address POST /api/map/locations/bulk-delete Delete multiple locations PUT /api/map/locations/:id Update location DELETE /api/map/locations/:id Delete location

Admin Roles: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/backend/modules/locations/#public-endpoints-no-authentication","title":"Public Endpoints (No Authentication)","text":"Method Path Description GET /api/map/locations/public Public locations for map (PII-filtered, 5000 limit)"},{"location":"v2/backend/modules/locations/#admin-endpoint-details","title":"Admin Endpoint Details","text":""},{"location":"v2/backend/modules/locations/#get-apimaplocations","title":"GET /api/map/locations","text":"

List locations with pagination, search, and filtering.

Query Parameters:

Parameter Type Required Default Description page number No 1 Page number limit number No 20 Results per page (max 100) search string No - Search address, first/last name, email supportLevel SupportLevel No - Filter by support level hasSign boolean No - Filter by sign presence confidenceLevel string No - Filter by geocode confidence: high (85+), medium (60-84), low (<60), none (0 or null) sortBy string No createdAt Sort field: createdAt, address, supportLevel sortOrder string No desc Sort order: asc, desc

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/locations?page=1&limit=20&supportLevel=LEVEL_1&hasSign=true&confidenceLevel=high\"\n

Response (200 OK):

{\n  \"locations\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"address\": \"123 Main St, Toronto, ON\",\n      \"unitNumber\": \"Apt 4\",\n      \"firstName\": \"John\",\n      \"lastName\": \"Doe\",\n      \"email\": \"john@example.com\",\n      \"phone\": \"416-555-1234\",\n      \"supportLevel\": \"LEVEL_1\",\n      \"sign\": true,\n      \"signSize\": \"Large\",\n      \"notes\": \"Willing to volunteer\",\n      \"buildingNotes\": \"Apartment building, intercom required\",\n      \"latitude\": 43.6532,\n      \"longitude\": -79.3832,\n      \"geocodeConfidence\": 95,\n      \"geocodeProvider\": \"NOMINATIM\",\n      \"postalCode\": \"M5H 2N2\",\n      \"province\": \"ON\",\n      \"federalDistrict\": \"Toronto Centre\",\n      \"buildingUse\": 1,\n      \"createdByUserId\": \"clxUser123\",\n      \"updatedByUserId\": null,\n      \"createdAt\": \"2026-02-08T12:00:00.000Z\",\n      \"updatedAt\": \"2026-02-08T12:00:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 342,\n    \"totalPages\": 18\n  }\n}\n

Search Logic:

if (search) {\n  where.OR = [\n    { address: { contains: search, mode: 'insensitive' } },\n    { firstName: { contains: search, mode: 'insensitive' } },\n    { lastName: { contains: search, mode: 'insensitive' } },\n    { email: { contains: search, mode: 'insensitive' } },\n  ];\n}\n

Confidence Level Filtering:

if (confidenceLevel === 'high') {\n  where.geocodeConfidence = { gte: 85 };\n} else if (confidenceLevel === 'medium') {\n  where.geocodeConfidence = { gte: 60, lt: 85 };\n} else if (confidenceLevel === 'low') {\n  where.geocodeConfidence = { lt: 60, gt: 0 };\n} else if (confidenceLevel === 'none') {\n  where.OR = [{ geocodeConfidence: null }, { geocodeConfidence: 0 }];\n}\n
"},{"location":"v2/backend/modules/locations/#get-apimaplocationsstats","title":"GET /api/map/locations/stats","text":"

Get aggregate statistics for locations.

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/locations/stats\"\n

Response (200 OK):

{\n  \"total\": 1247,\n  \"supportLevels\": {\n    \"LEVEL_1\": 342,\n    \"LEVEL_2\": 189,\n    \"LEVEL_3\": 276,\n    \"LEVEL_4\": 98,\n    \"NONE\": 342\n  },\n  \"signs\": 142,\n  \"geocoded\": 1189,\n  \"ungeocoded\": 58,\n  \"confidence\": {\n    \"high\": 892,\n    \"medium\": 213,\n    \"low\": 84,\n    \"none\": 58,\n    \"average\": 87\n  },\n  \"providers\": {\n    \"nominatim\": 654,\n    \"mapbox\": 312,\n    \"arcgis\": 98,\n    \"photon\": 76,\n    \"google\": 34,\n    \"locationiq\": 15,\n    \"manual\": 58\n  }\n}\n

Field Descriptions:

"},{"location":"v2/backend/modules/locations/#post-apimaplocations","title":"POST /api/map/locations","text":"

Create new location with automatic geocoding.

Request Body:

{\n  \"address\": \"123 Main St, Toronto, ON\",\n  \"unitNumber\": \"Apt 4\",\n  \"firstName\": \"John\",\n  \"lastName\": \"Doe\",\n  \"email\": \"john@example.com\",\n  \"phone\": \"416-555-1234\",\n  \"supportLevel\": \"LEVEL_1\",\n  \"sign\": true,\n  \"signSize\": \"Large\",\n  \"notes\": \"Willing to volunteer\",\n  \"buildingNotes\": \"Apartment building, intercom required\"\n}\n

Response (201 Created):

Returns created location object.

Auto-Geocoding:

If address provided and no latitude/longitude, automatically geocodes:

if (data.address && data.latitude == null && data.longitude == null) {\n  const result = await geocodingService.geocode(data.address);\n  if (result) {\n    createData.latitude = result.latitude;\n    createData.longitude = result.longitude;\n    createData.geocodeConfidence = result.confidence;\n    createData.geocodeProvider = result.provider;\n  }\n}\n

History Tracking:

Creates LocationHistory record with action GEOCODED (if geocoded) or CREATED (if manual coordinates).

"},{"location":"v2/backend/modules/locations/#put-apimaplocationsid","title":"PUT /api/map/locations/:id","text":"

Update location. Re-geocodes if address changes without explicit lat/lng.

Request Body (Partial):

{\n  \"address\": \"456 Oak St, Toronto, ON\",\n  \"supportLevel\": \"LEVEL_2\"\n}\n

Response (200 OK):

Returns updated location object.

Smart Geocoding:

History Tracking:

Records field changes with before/after values:

// Track changes\nconst changes: { field: string; oldValue: unknown; newValue: unknown }[] = [];\n\nif (data.address && data.address !== existing.address) {\n  changes.push({ field: 'address', oldValue: existing.address, newValue: data.address });\n}\n\n// Determine action based on changes\nlet action: LocationHistoryAction = LocationHistoryAction.UPDATED;\n\nif (data.latitude !== undefined && data.latitude !== existing.latitude) {\n  action = LocationHistoryAction.MOVED_ON_MAP; // Explicit coordinate change (map drag)\n}\n\nif (address changed && auto-geocoded) {\n  action = LocationHistoryAction.GEOCODED;\n}\n
"},{"location":"v2/backend/modules/locations/#post-apimaplocationsimport-csv","title":"POST /api/map/locations/import-csv","text":"

Upload and import CSV file with flexible column mapping.

Multipart Form Data:

Supported Column Names (Case-Insensitive):

Field Column Names address address, street, street address firstName first name, firstname, first lastName last name, lastname, last email email, e-mail phone phone, telephone, tel, phone number unitNumber unit, unit number, apt, apartment, suite supportLevel support level, supportlevel, support, level sign sign, lawn sign signSize sign size, signsize notes notes, note, comments latitude latitude, lat longitude longitude, lng, lon

Example CSV:

address,first name,last name,email,phone,support level,sign\n\"123 Main St, Toronto, ON\",John,Doe,john@example.com,416-555-1234,LEVEL_1,true\n\"456 Oak St, Toronto, ON\",Jane,Smith,jane@example.com,416-555-5678,LEVEL_2,false\n

Example Request:

curl -X POST -H \"Authorization: Bearer <token>\" \\\n  -F \"file=@locations.csv\" \\\n  \"http://api.cmlite.org/api/map/locations/import-csv\"\n

Response (200 OK):

{\n  \"total\": 1000,\n  \"success\": 942,\n  \"warnings\": 34,\n  \"failed\": 24,\n  \"errors\": [\n    \"Row 12: Missing address\",\n    \"Row 45: Invalid email format\",\n    \"Row 89: Geocoding failed\"\n  ]\n}\n

Field Descriptions:

Geocoding:

"},{"location":"v2/backend/modules/locations/#post-apimaplocationsimport-bulk","title":"POST /api/map/locations/import-bulk","text":"

Bulk import NAR (National Address Register) or standard CSV with advanced filtering.

Multipart Form Data:

Request Timeout: 5 minutes (extended for large files)

Example Request (NAR Import with Cut Filter):

curl -X POST -H \"Authorization: Bearer <token>\" \\\n  -F \"file=@Address_24_part_1.csv\" \\\n  -F \"format=nar\" \\\n  -F \"filterType=cut\" \\\n  -F \"cutId=clxCut123\" \\\n  -F \"residentialOnly=true\" \\\n  -F \"deduplicateRadius=5\" \\\n  \"http://api.cmlite.org/api/map/locations/import-bulk\"\n

Response (200 OK):

{\n  \"total\": 50000,\n  \"created\": 12847,\n  \"skippedDuplicate\": 1243,\n  \"skippedOutOfBounds\": 34892,\n  \"skippedInvalid\": 1018,\n  \"errors\": [\n    \"Row 234: Invalid coordinates\",\n    \"Row 1892: Missing civic number\"\n  ]\n}\n

NAR Format Support:

2025 NAR Format (Recommended):

Legacy NAR Format (Backward Compatible):

Auto-Detection:

If 3+ NAR-specific columns detected, automatically treats as NAR format.

Lambert Projection Conversion:

import proj4 from 'proj4';\n\n// Define EPSG:3347 (Statistics Canada Lambert Conformal Conic)\nproj4.defs('EPSG:3347', '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=63.390675 +lon_0=-91.86666666666666 +x_0=6200000 +y_0=3000000 +ellps=GRS80 +units=m +no_defs');\n\nfunction lambertToLatLng(bgX: number, bgY: number): [number, number] {\n  const [lng, lat] = proj4('EPSG:3347', 'EPSG:4326', [bgX, bgY]);\n  return [lat, lng];\n}\n

Filtering Options:

  1. Cut Filter (filterType=cut):
  2. Only imports locations inside specified cut polygon
  3. Uses point-in-polygon ray-casting algorithm

  4. Map Area Filter (filterType=mapArea):

  5. Imports locations visible on current map view
  6. Calculates bounding box from MapSettings (center, zoom)

  7. City Filter (filterType=city):

  8. Imports locations matching city name (case-insensitive)

  9. Province Filter (filterType=province):

  10. Imports locations matching province code (e.g., ON, BC)

Deduplication:

Prevents duplicate locations at same coordinates:

const coordKey = `${roundCoord(lat, 5)}:${roundCoord(lng, 5)}`; // 5 decimal places = ~1.1m precision\n\nif (existingCoords.has(coordKey) || inFileCoords.has(coordKey)) {\n  skippedDuplicate++;\n  continue;\n}\n

Batch Processing:

Inserts locations in batches (default 1000) for performance:

const batch: Prisma.LocationCreateManyInput[] = [];\n\n// ... collect locations ...\n\nif (batch.length >= options.batchSize) {\n  await prisma.location.createMany({ data: batch, skipDuplicates: true });\n  batch.length = 0;\n}\n
"},{"location":"v2/backend/modules/locations/#get-apimaplocationsexport-csv","title":"GET /api/map/locations/export-csv","text":"

Export locations as CSV download.

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/locations/export-csv\" \\\n  -o locations.csv\n

Response (200 OK):

CSV file with headers:

address,firstName,lastName,email,phone,unitNumber,supportLevel,sign,signSize,notes,latitude,longitude,geocodeConfidence,geocodeProvider,createdAt\n\"123 Main St, Toronto, ON\",John,Doe,john@example.com,416-555-1234,Apt 4,LEVEL_1,Yes,Large,Willing to volunteer,43.6532,-79.3832,95,NOMINATIM,2026-02-08T12:00:00.000Z\n
"},{"location":"v2/backend/modules/locations/#post-apimaplocationsreverse-geocode","title":"POST /api/map/locations/reverse-geocode","text":"

Reverse geocode coordinates to address.

Request Body:

{\n  \"latitude\": 43.6532,\n  \"longitude\": -79.3832\n}\n

Response (200 OK):

{\n  \"address\": \"123 Main St, Toronto, ON M5H 2N2, Canada\",\n  \"provider\": \"NOMINATIM\",\n  \"confidence\": 85\n}\n

Use Cases:

"},{"location":"v2/backend/modules/locations/#get-apimaplocationsall","title":"GET /api/map/locations/all","text":"

Get all geocoded locations for admin map view.

Query Parameters:

Parameter Type Description minLat number Minimum latitude (bounding box) maxLat number Maximum latitude minLng number Minimum longitude maxLng number Maximum longitude

Example Request:

# All locations\ncurl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/locations/all\"\n\n# Bounding box (visible map area)\ncurl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/locations/all?minLat=43.6&maxLat=43.7&minLng=-79.4&maxLng=-79.3\"\n

Response (200 OK):

Returns array of location objects (max 5000).

Safety Limit:

If result hits 5000 locations, adds header X-Location-Limit-Hit: true to warn client.

"},{"location":"v2/backend/modules/locations/#get-apimaplocationsidhistory","title":"GET /api/map/locations/:id/history","text":"

Get location edit history with audit trail.

Query Parameters:

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/locations/clx1234567890/history?page=1&limit=20\"\n

Response (200 OK):

{\n  \"history\": [\n    {\n      \"id\": \"clxHistory123\",\n      \"locationId\": \"clx1234567890\",\n      \"userId\": \"clxUser123\",\n      \"user\": {\n        \"id\": \"clxUser123\",\n        \"email\": \"admin@example.com\",\n        \"name\": \"Admin User\",\n        \"role\": \"SUPER_ADMIN\"\n      },\n      \"action\": \"MOVED_ON_MAP\",\n      \"field\": \"latitude\",\n      \"oldValue\": \"43.6532\",\n      \"newValue\": \"43.6540\",\n      \"metadata\": null,\n      \"createdAt\": \"2026-02-11T12:00:00.000Z\"\n    },\n    {\n      \"id\": \"clxHistory124\",\n      \"locationId\": \"clx1234567890\",\n      \"userId\": \"clxUser123\",\n      \"user\": {...},\n      \"action\": \"GEOCODED\",\n      \"field\": \"latitude\",\n      \"oldValue\": null,\n      \"newValue\": \"43.6532\",\n      \"metadata\": {\n        \"provider\": \"NOMINATIM\",\n        \"confidence\": 95,\n        \"geocoded\": true\n      },\n      \"createdAt\": \"2026-02-08T12:00:00.000Z\"\n    },\n    {\n      \"id\": \"clxHistory125\",\n      \"locationId\": \"clx1234567890\",\n      \"userId\": \"clxUser123\",\n      \"user\": {...},\n      \"action\": \"CREATED\",\n      \"field\": null,\n      \"oldValue\": null,\n      \"newValue\": null,\n      \"metadata\": null,\n      \"createdAt\": \"2026-02-08T12:00:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 7,\n    \"totalPages\": 1\n  }\n}\n

History Actions:

"},{"location":"v2/backend/modules/locations/#public-endpoint-details","title":"Public Endpoint Details","text":""},{"location":"v2/backend/modules/locations/#get-apimaplocationspublic","title":"GET /api/map/locations/public","text":"

Get locations for public map (PII-filtered).

Query Parameters:

Example Request:

curl \"http://api.cmlite.org/api/public/map/locations?minLat=43.6&maxLat=43.7&minLng=-79.4&maxLng=-79.3\"\n

Response (200 OK):

[\n  {\n    \"id\": \"clx1234567890\",\n    \"latitude\": 43.6532,\n    \"longitude\": -79.3832,\n    \"supportLevel\": \"LEVEL_1\",\n    \"sign\": true,\n    \"signSize\": \"Large\",\n    \"unitNumber\": \"Apt 4\",\n    \"address\": \"123 Main St, Toronto, ON\"\n  }\n]\n

PII Filtering:

Only returns non-sensitive fields:

"},{"location":"v2/backend/modules/locations/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/locations/#locationsservicecreatedata-userid","title":"locationsService.create(data, userId)","text":"

Create location with auto-geocoding.

Auto-Geocoding Logic:

if (data.address && data.latitude == null && data.longitude == null) {\n  const result = await geocodingService.geocode(data.address);\n  if (result) {\n    createData.latitude = result.latitude;\n    createData.longitude = result.longitude;\n    createData.geocodeConfidence = result.confidence;\n    createData.geocodeProvider = result.provider;\n  }\n}\n

History Recording:

Creates history record in transaction:

const location = await prisma.$transaction(async (tx) => {\n  const newLocation = await tx.location.create({ data: createData });\n\n  await tx.locationHistory.create({\n    data: {\n      locationId: newLocation.id,\n      userId,\n      action: geocodeMetadata ? LocationHistoryAction.GEOCODED : LocationHistoryAction.CREATED,\n      metadata: geocodeMetadata,\n    },\n  });\n\n  return newLocation;\n});\n
"},{"location":"v2/backend/modules/locations/#locationsserviceupdateid-data-userid","title":"locationsService.update(id, data, userId)","text":"

Update location with smart geocoding and history tracking.

Smart Geocoding:

Action Detection:

let action: LocationHistoryAction = LocationHistoryAction.UPDATED;\n\n// Explicit coordinate change (map drag)\nif (data.latitude !== undefined && data.latitude !== existing.latitude) {\n  action = LocationHistoryAction.MOVED_ON_MAP;\n}\n\n// Auto-geocode on address change\nif (data.address && data.address !== existing.address && !data.latitude && !data.longitude) {\n  const result = await geocodingService.geocode(data.address);\n  if (result) {\n    updateData.latitude = result.latitude;\n    updateData.longitude = result.longitude;\n    action = LocationHistoryAction.GEOCODED;\n  }\n}\n

Change Tracking:

const changes: { field: string; oldValue: unknown; newValue: unknown }[] = [];\n\nconst fieldsToTrack = ['address', 'firstName', 'lastName', 'email', 'phone', 'unitNumber', 'supportLevel', 'sign', 'signSize', 'notes'];\n\nfor (const field of fieldsToTrack) {\n  if (data[field] !== undefined && data[field] !== existing[field]) {\n    changes.push({ field, oldValue: existing[field], newValue: data[field] });\n  }\n}\n\n// Record all changes in transaction\nawait tx.locationHistory.createMany({ data: historyRecords });\n
"},{"location":"v2/backend/modules/locations/#locationsserviceimportfromcsvbuffer-userid","title":"locationsService.importFromCsv(buffer, userId)","text":"

Import CSV with flexible column mapping.

Column Mapping:

const CSV_HEADER_MAP: Record<string, keyof CsvRow> = {\n  'address': 'address',\n  'street': 'address',\n  'street address': 'address',\n  'first name': 'firstName',\n  'firstname': 'firstName',\n  // ... 50+ mappings\n};\n

Processing:

  1. Parse CSV with csv-parse library
  2. Detect column mapping from headers
  3. For each row:
  4. Validate required fields (address)
  5. Parse support level, sign boolean
  6. Use provided lat/lng or geocode address
  7. Create location in database
  8. Return summary statistics
"},{"location":"v2/backend/modules/locations/#locationsserviceimportbulkbuffer-userid-options-filters","title":"locationsService.importBulk(buffer, userId, options, filters)","text":"

Bulk import NAR or standard CSV with advanced filtering.

NAR Format Detection:

function detectNarFormat(headers: string[]): boolean {\n  const NAR_DETECT_COLUMNS = [\n    'CIVIC_NO', 'OFFICIAL_STREET_NAME', 'BG_X', 'BG_Y', // 2025 format\n    'STR_NBR', 'STR_NME', 'LAT', 'LNG',                 // Legacy format\n  ];\n\n  const normalizedHeaders = headers.map((h) => h.trim().toUpperCase());\n  let matchCount = 0;\n\n  for (const col of NAR_DETECT_COLUMNS) {\n    if (normalizedHeaders.includes(col)) matchCount++;\n  }\n\n  return matchCount >= 3; // At least 3 NAR columns\n}\n

3-Phase Processing:

Phase 1: Parse & Filter

// Parse all records\nfor (const record of records) {\n  // Build address from NAR fields\n  const civicNo = getValue('CIVIC_NO');\n  const streetName = getValue('STREET_NAME');\n  const address = [civicNo, streetName, ...].join(' ');\n\n  // Apply filters\n  if (filters?.city && !matchesCity(address, filters.city)) {\n    skippedOutOfBounds++;\n    continue;\n  }\n\n  // Residential filter\n  if (options.residentialOnly && buildingUse === 3) {\n    skippedOutOfBounds++;\n    continue;\n  }\n\n  parsedRecords.push({ address, lat, lng, needsGeocoding });\n}\n

Phase 2: Batch Geocode

// Collect addresses needing geocoding\nconst addressesToGeocode: string[] = parsedRecords\n  .filter(r => r.needsGeocoding)\n  .map(r => r.address);\n\n// Batch geocode (parallel)\nconst geocodeResults = await geocodingService.geocodeBatch(addressesToGeocode);\n

Phase 3: Create Records

const batch: Prisma.LocationCreateManyInput[] = [];\n\nfor (const parsed of parsedRecords) {\n  // Apply geocoding result\n  if (parsed.needsGeocoding) {\n    const result = geocodeResults[geocodeIndex];\n    if (result) {\n      lat = result.latitude;\n      lng = result.longitude;\n    }\n  }\n\n  // Cut polygon filter\n  if (filters?.cutPolygon) {\n    if (!isPointInPolygon(lat, lng, cutPolygon)) {\n      skippedOutOfBounds++;\n      continue;\n    }\n  }\n\n  // Deduplication\n  if (existingCoords.has(coordKey)) {\n    skippedDuplicate++;\n    continue;\n  }\n\n  batch.push({ address, lat, lng, ... });\n\n  // Flush batch\n  if (batch.length >= options.batchSize) {\n    await prisma.location.createMany({ data: batch });\n    batch.length = 0;\n  }\n}\n
"},{"location":"v2/backend/modules/locations/#locationsserviceexporttocsvfilters","title":"locationsService.exportToCsv(filters?)","text":"

Export locations as CSV.

CSV Generation:

import { stringify } from 'csv-stringify/sync';\n\nconst rows = locations.map((loc) => ({\n  address: loc.address || '',\n  firstName: loc.firstName || '',\n  lastName: loc.lastName || '',\n  email: loc.email || '',\n  phone: loc.phone || '',\n  unitNumber: loc.unitNumber || '',\n  supportLevel: loc.supportLevel || '',\n  sign: loc.sign ? 'Yes' : 'No',\n  signSize: loc.signSize || '',\n  notes: loc.notes || '',\n  latitude: loc.latitude?.toString() || '',\n  longitude: loc.longitude?.toString() || '',\n  geocodeConfidence: loc.geocodeConfidence?.toString() || '',\n  geocodeProvider: loc.geocodeProvider || '',\n  createdAt: loc.createdAt.toISOString(),\n}));\n\nreturn stringify(rows, { header: true });\n
"},{"location":"v2/backend/modules/locations/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/locations/#create-location-schema","title":"Create Location Schema","text":"
export const createLocationSchema = z.object({\n  address: z.string().min(1, 'Address is required'),\n  firstName: z.string().optional(),\n  lastName: z.string().optional(),\n  email: z.string().email().optional().or(z.literal('')),\n  phone: z.string().optional(),\n  unitNumber: z.string().optional(),\n  supportLevel: z.nativeEnum(SupportLevel).optional(),\n  sign: z.boolean().optional().default(false),\n  signSize: z.string().optional(),\n  notes: z.string().optional(),\n  buildingNotes: z.string().max(2000).optional(),\n  latitude: z.number().min(-90).max(90).optional(),\n  longitude: z.number().min(-180).max(180).optional(),\n});\n
"},{"location":"v2/backend/modules/locations/#bulk-import-schema","title":"Bulk Import Schema","text":"
export const bulkImportSchema = z.object({\n  format: z.enum(['standard', 'nar']).default('standard'),\n  filterType: z.enum(['none', 'cut', 'mapArea', 'city', 'province']).default('none'),\n  cutId: z.string().optional(),\n  filterCity: z.string().optional(),\n  filterProvince: z.string().optional(),\n  residentialOnly: z.coerce.boolean().default(false),\n  deduplicateRadius: z.coerce.number().min(0).max(100).default(5),\n  skipGeocoding: z.coerce.boolean().default(true),\n  batchSize: z.coerce.number().int().min(100).max(5000).default(1000),\n});\n
"},{"location":"v2/backend/modules/locations/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/locations/#admin-create-location-with-auto-geocoding","title":"Admin: Create Location with Auto-Geocoding","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst createLocation = async () => {\n  try {\n    const { data } = await api.post('/api/map/locations', {\n      address: '123 Main St, Toronto, ON',\n      firstName: 'John',\n      lastName: 'Doe',\n      email: 'john@example.com',\n      supportLevel: 'LEVEL_1',\n      sign: true,\n    });\n\n    message.success('Location created and geocoded');\n    console.log(`Created at: ${data.latitude}, ${data.longitude}`);\n    console.log(`Confidence: ${data.geocodeConfidence}%`);\n  } catch (error) {\n    message.error('Failed to create location');\n  }\n};\n
"},{"location":"v2/backend/modules/locations/#admin-import-nar-file-with-cut-filter","title":"Admin: Import NAR File with Cut Filter","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst importNAR = async (file: File, cutId: string) => {\n  const formData = new FormData();\n  formData.append('file', file);\n  formData.append('format', 'nar');\n  formData.append('filterType', 'cut');\n  formData.append('cutId', cutId);\n  formData.append('residentialOnly', 'true');\n  formData.append('deduplicateRadius', '5');\n\n  try {\n    const { data } = await api.post('/api/map/locations/import-bulk', formData, {\n      headers: { 'Content-Type': 'multipart/form-data' },\n      timeout: 300000, // 5 minutes\n    });\n\n    message.success(`Created ${data.created} locations`);\n    console.log(`Skipped ${data.skippedDuplicate} duplicates`);\n    console.log(`Skipped ${data.skippedOutOfBounds} out of bounds`);\n  } catch (error) {\n    message.error('NAR import failed');\n  }\n};\n
"},{"location":"v2/backend/modules/locations/#admin-export-locations","title":"Admin: Export Locations","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst exportLocations = async () => {\n  try {\n    const { data } = await api.get('/api/map/locations/export-csv', {\n      responseType: 'blob',\n    });\n\n    const url = window.URL.createObjectURL(new Blob([data]));\n    const link = document.createElement('a');\n    link.href = url;\n    link.setAttribute('download', 'locations.csv');\n    document.body.appendChild(link);\n    link.click();\n    link.remove();\n\n    message.success('Locations exported');\n  } catch (error) {\n    message.error('Export failed');\n  }\n};\n
"},{"location":"v2/backend/modules/locations/#frontend-integration","title":"Frontend Integration","text":"

The LocationsPage component (admin/src/pages/LocationsPage.tsx) provides:

State Management:

const [locations, setLocations] = useState<Location[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });\nconst [filters, setFilters] = useState({ search: '', supportLevel: null, hasSign: null, confidenceLevel: null });\nconst [stats, setStats] = useState({ total: 0, supportLevels: {}, signs: 0, geocoded: 0, ungeocoded: 0, confidence: {}, providers: {} });\n
"},{"location":"v2/backend/modules/locations/#performance-considerations","title":"Performance Considerations","text":"

Batch Processing:

Deduplication:

Indexing:

Safety Limits:

Geocoding:

"},{"location":"v2/backend/modules/locations/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/locations/#issue-csv-import-fails-with-invalid-csv-file-format","title":"Issue: CSV import fails with \"Invalid CSV file format\"","text":"

Cause: CSV not UTF-8 encoded or has malformed rows

Solution:

"},{"location":"v2/backend/modules/locations/#issue-nar-import-skips-all-records-skippedoutofbounds-total","title":"Issue: NAR import skips all records (skippedOutOfBounds = total)","text":"

Cause: Cut/city/province filter doesn't match any records

Solution:

"},{"location":"v2/backend/modules/locations/#issue-geocoding-confidence-is-low-60-for-many-locations","title":"Issue: Geocoding confidence is low (<60) for many locations","text":"

Cause: Incomplete addresses or geocoding provider limitations

Solution:

"},{"location":"v2/backend/modules/locations/#issue-bulk-import-times-out-after-5-minutes","title":"Issue: Bulk import times out after 5 minutes","text":"

Cause: File too large or too many locations to geocode

Solution:

"},{"location":"v2/backend/modules/locations/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/backend/modules/media/","title":"Media Module (Fastify Video Library API)","text":""},{"location":"v2/backend/modules/media/#overview","title":"Overview","text":"

The Media module is a separate Fastify microservice running on port 4100 (separate from the main Express API on port 4000). It provides a complete video library management system with public gallery features, reaction tracking, and job queue for video processing. The module uses Drizzle ORM (unlike the main API's Prisma ORM) and shares the same PostgreSQL database.

Key Features:

"},{"location":"v2/backend/modules/media/#file-paths","title":"File Paths","text":"File Purpose api/src/media-server.ts Fastify server entry point (port 4100) api/src/modules/media/db/schema.ts Drizzle schema (15+ tables, 1,400+ lines) api/src/modules/media/routes/videos.routes.ts Video CRUD routes (99 lines) api/src/modules/media/routes/public-media.routes.ts Public gallery routes (12,852 lines) api/src/modules/media/routes/reactions.routes.ts Reaction routes (135 lines) api/src/modules/media/routes/comments.routes.ts Comment routes (4,827 lines) api/src/modules/media/middleware/auth.ts Fastify auth middleware (JWT verification) api/src/modules/media/types/enums.ts Shared enums"},{"location":"v2/backend/modules/media/#database-models-drizzle-orm","title":"Database Models (Drizzle ORM)","text":""},{"location":"v2/backend/modules/media/#videos-table","title":"Videos Table","text":"
export const videos = pgTable('videos', {\n  id: serial('id').primaryKey(),\n  path: text('path').notNull().unique(),\n  filename: text('filename').notNull(),\n  producer: text('producer'),\n  creator: text('creator'),\n  title: text('title'),\n  durationSeconds: integer('duration_seconds'),\n  quality: text('quality'),\n  orientation: text('orientation'),\n  hasAudio: boolean('has_audio').default(true),\n  fileSize: bigint('file_size', { mode: 'number' }),\n  fileHash: text('file_hash'),\n  width: integer('width'),\n  height: integer('height'),\n  lastValidated: timestamp('last_validated', { withTimezone: true }),\n  isValid: boolean('is_valid').default(true),\n  thumbnailPath: text('thumbnail_path'),\n  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),\n  tags: jsonb('tags').$type<string[]>(),\n\n  // Directory type for efficient filtering\n  directoryType: text('directory_type').$type<DirectoryType>(),\n\n  // Historical engagement stats (preserved when moved from public media)\n  publicViewCount: integer('public_view_count'),\n  publicUpvoteCount: integer('public_upvote_count'),\n  publicCommentCount: integer('public_comment_count'),\n  publicCompletionCount: integer('public_completion_count'),\n  publicTotalWatchTime: integer('public_total_watch_time'),\n  movedFromPublicAt: timestamp('moved_from_public_at', { withTimezone: true }),\n\n  // Name standardization tracking\n  originalFilename: text('original_filename'),\n  originalPath: text('original_path'),\n  standardizedAt: timestamp('standardized_at', { withTimezone: true }),\n}, (table) => ({\n  orientationIdx: index('idx_orientation').on(table.orientation),\n  producerIdx: index('idx_producer').on(table.producer),\n  isValidIdx: index('idx_is_valid').on(table.isValid),\n  directoryTypeIdx: index('idx_directory_type').on(table.directoryType),\n  fingerprintIdx: index('idx_videos_fingerprint').on(\n    table.durationSeconds, table.fileSize, table.width, table.height\n  ),\n  directoryValidOrientationIdx: index('idx_videos_directory_valid_orientation').on(\n    table.directoryType, table.isValid, table.orientation\n  ),\n}));\n\n// Directory types\nexport const DIRECTORY_TYPES = [\n  'studios', 'gifs', 'private', 'inbox', 'curated',\n  'playback', 'compilations', 'videos', 'highlights'\n] as const;\nexport type DirectoryType = typeof DIRECTORY_TYPES[number];\n

Key Features:

"},{"location":"v2/backend/modules/media/#public-media-table","title":"Public Media Table","text":"
export const publicMedia = pgTable('public_media', {\n  id: serial('id').primaryKey(),\n  path: text('path').notNull().unique(),\n  filename: text('filename').notNull(),\n  category: text('category').notNull(),\n  durationSeconds: integer('duration_seconds'),\n  quality: text('quality'),\n  orientation: text('orientation'),\n  thumbnailPath: text('thumbnail_path'),\n  fileSize: bigint('file_size', { mode: 'number' }),\n\n  // Denormalized counters for performance\n  viewCount: integer('view_count').default(0),\n  upvoteCount: integer('upvote_count').default(0),\n  commentCount: integer('comment_count').default(0),\n  finishCount: integer('finish_count').default(0),\n  totalWatchTime: integer('total_watch_time').default(0),\n\n  // Lock system\n  isLocked: boolean('is_locked').default(false),\n  lockedAt: timestamp('locked_at', { withTimezone: true }),\n  lockedReason: text('locked_reason'),\n\n  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),\n  updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),\n}, (table) => ({\n  categoryIdx: index('idx_public_media_category').on(table.category),\n  orientationIdx: index('idx_public_media_orientation').on(table.orientation),\n  viewCountIdx: index('idx_public_media_views').on(table.viewCount),\n  upvoteCountIdx: index('idx_public_media_upvotes').on(table.upvoteCount),\n  isLockedIdx: index('idx_public_media_locked').on(table.isLocked),\n}));\n

Key Features:

"},{"location":"v2/backend/modules/media/#upvotes-table","title":"Upvotes Table","text":"
export const upvotes = pgTable('upvotes', {\n  id: serial('id').primaryKey(),\n  mediaId: integer('media_id').notNull(),\n  sessionId: text('session_id').notNull(),\n  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),\n}, (table) => ({\n  uniqueVoteIdx: index('idx_upvotes_unique').on(table.mediaId, table.sessionId),\n  mediaIdx: index('idx_upvotes_media').on(table.mediaId),\n}));\n

Key Features:

"},{"location":"v2/backend/modules/media/#video-reactions-table","title":"Video Reactions Table","text":"
export const REACTION_TYPES = ['like', 'love', 'laugh', 'wow', 'sad', 'angry'] as const;\nexport type ReactionType = typeof REACTION_TYPES[number];\n\nexport const videoReactions = pgTable('video_reactions', {\n  id: serial('id').primaryKey(),\n  userId: integer('user_id').notNull(),\n  mediaId: integer('media_id').notNull(),\n  reactionType: text('reaction_type').notNull(),\n  videoTimestamp: integer('video_timestamp').notNull(), // seconds into video\n  createdAt: timestamp('created_at', { withTimezone: true }).notNull(),\n}, (table) => ({\n  userMediaTypeIdx: index('idx_video_reactions_user_media_type').on(\n    table.userId, table.mediaId, table.reactionType\n  ),\n  mediaTimestampIdx: index('idx_video_reactions_media_timestamp').on(\n    table.mediaId, table.videoTimestamp\n  ),\n  mediaIdx: index('idx_video_reactions_media').on(table.mediaId),\n  createdAtIdx: index('idx_video_reactions_created').on(table.createdAt),\n}));\n

Reaction Emojis:

Type Emoji Label like \ud83d\udc4d Like love \u2764\ufe0f Love laugh \ud83d\ude02 Laugh wow \ud83d\ude2e Wow sad \ud83d\ude22 Sad angry \ud83d\ude20 Angry

Key Features:

"},{"location":"v2/backend/modules/media/#jobs-table","title":"Jobs Table","text":"
export type ResourceCategory = 'gpu_ai' | 'gpu_encode' | 'cpu';\nexport type JobStatus = 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';\n\nexport const jobs = pgTable('jobs', {\n  id: serial('id').primaryKey(),\n  type: text('type').notNull(),\n  status: text('status').default('pending').$type<JobStatus>(),\n  progress: integer('progress').default(0),\n  log: text('log'),\n  params: jsonb('params').$type<Record<string, unknown>>(),\n  startedAt: timestamp('started_at', { withTimezone: true }),\n  completedAt: timestamp('completed_at', { withTimezone: true }),\n  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),\n\n  // Queue management\n  resourceCategory: text('resource_category').default('cpu').$type<ResourceCategory>(),\n  vramRequired: integer('vram_required').default(0),\n  queuePosition: integer('queue_position'),\n  waitingReason: text('waiting_reason'),\n  priority: integer('priority').default(5),\n\n  // Pipeline integration\n  pipelineId: integer('pipeline_id'),\n  pipelineStepId: integer('pipeline_step_id'),\n}, (table) => ({\n  queueIdx: index('idx_jobs_queue').on(table.status, table.priority, table.createdAt),\n  resourceIdx: index('idx_jobs_resource').on(table.resourceCategory, table.status),\n  pipelineIdx: index('idx_jobs_pipeline').on(table.pipelineId),\n}));\n

Job Types:

Resource Categories:

"},{"location":"v2/backend/modules/media/#compilations-table","title":"Compilations Table","text":"
export const compilations = pgTable('compilations', {\n  id: serial('id').primaryKey(),\n  filename: text('filename').notNull(),\n  path: text('path'),\n  durationSeconds: integer('duration_seconds'),\n  videoIds: jsonb('video_ids').$type<number[]>(),\n  settings: jsonb('settings').$type<Record<string, unknown>>(),\n  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),\n});\n

Key Features:

"},{"location":"v2/backend/modules/media/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/media/#admin-endpoints-videos","title":"Admin Endpoints (Videos)","text":"Method Path Auth Description GET /api/videos Admin roles List videos with pagination GET /api/videos/:id Admin roles Get single video GET /api/videos/health None Health check

Admin Roles: Requires admin role via Fastify auth middleware

"},{"location":"v2/backend/modules/media/#public-media-endpoints","title":"Public Media Endpoints","text":"Method Path Auth Description GET /api/media/public None List shared media (paginated, filterable, sorted) GET /api/media/public/:id None Get single media + increment view count POST /api/media/public/:id/upvote None Upvote media (session-based) DELETE /api/media/public/:id/upvote None Remove upvote POST /api/media/public/:id/finish None Mark video as finished POST /api/media/public/:id/watch-time None Track watch time"},{"location":"v2/backend/modules/media/#reaction-endpoints","title":"Reaction Endpoints","text":"Method Path Auth Description POST /api/reactions Required Add reaction to video GET /api/reactions None Get reactions (filterable by mediaId/userId) GET /api/reactions/config None Get available reaction types"},{"location":"v2/backend/modules/media/#comment-endpoints","title":"Comment Endpoints","text":"Method Path Auth Description POST /api/media/comments Optional Add comment (auth optional, session-based) GET /api/media/comments None List comments for media"},{"location":"v2/backend/modules/media/#endpoint-details","title":"Endpoint Details","text":""},{"location":"v2/backend/modules/media/#get-apivideos","title":"GET /api/videos","text":"

List videos with pagination and search (admin only).

Query Parameters:

Parameter Type Default Description limit number 50 Results per page (max 100) offset number 0 Skip N results search string - Search title (case-insensitive)

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://localhost:4100/api/videos?limit=20&offset=0&search=demo\"\n

Response (200 OK):

{\n  \"videos\": [\n    {\n      \"id\": 123,\n      \"title\": \"Demo Video\",\n      \"filename\": \"demo-video.mp4\",\n      \"duration\": 300,\n      \"fileSize\": 52428800,\n      \"width\": 1920,\n      \"height\": 1080,\n      \"createdAt\": \"2026-02-01T12:00:00.000Z\",\n      \"updatedAt\": \"2026-02-11T14:30:00.000Z\"\n    }\n  ],\n  \"total\": 45,\n  \"limit\": 20,\n  \"offset\": 0\n}\n
"},{"location":"v2/backend/modules/media/#get-apimediapublic","title":"GET /api/media/public","text":"

List shared media with pagination, filtering, and sorting (no auth required).

Query Parameters:

Parameter Type Default Description category string - Filter by category search string - Search filename/path sort enum recent Sort: recent, popular, most_viewed orientation string - Filter by orientation limit number 24 Results per page (max 100) offset number 0 Skip N results

Example Request:

curl \"http://localhost:4100/api/media/public?category=highlights&sort=popular&limit=12\"\n

Response (200 OK):

{\n  \"videos\": [\n    {\n      \"id\": 456,\n      \"filename\": \"highlight-2024-01-15.mp4\",\n      \"category\": \"highlights\",\n      \"durationSeconds\": 45,\n      \"quality\": \"1080p\",\n      \"orientation\": \"landscape\",\n      \"thumbnailPath\": \"/thumbnails/highlight-2024-01-15.jpg\",\n      \"viewCount\": 1250,\n      \"upvoteCount\": 89,\n      \"commentCount\": 12,\n      \"isLocked\": false,\n      \"createdAt\": \"2026-01-15T10:00:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"total\": 145,\n    \"limit\": 12,\n    \"offset\": 0,\n    \"hasMore\": true\n  }\n}\n

Sort Modes:

switch (sort) {\n  case 'popular':\n    orderBy = [desc(publicMedia.upvoteCount), desc(publicMedia.createdAt)];\n    break;\n  case 'most_viewed':\n    orderBy = [desc(publicMedia.viewCount), desc(publicMedia.createdAt)];\n    break;\n  case 'recent':\n  default:\n    orderBy = [desc(publicMedia.createdAt)];\n    break;\n}\n
"},{"location":"v2/backend/modules/media/#get-apimediapublicid","title":"GET /api/media/public/:id","text":"

Get single media details and increment view count (no auth required).

Path Parameters:

Example Request:

curl \"http://localhost:4100/api/media/public/456\"\n

Response (200 OK):

{\n  \"id\": 456,\n  \"path\": \"/public/highlights/highlight-2024-01-15.mp4\",\n  \"filename\": \"highlight-2024-01-15.mp4\",\n  \"category\": \"highlights\",\n  \"durationSeconds\": 45,\n  \"quality\": \"1080p\",\n  \"orientation\": \"landscape\",\n  \"thumbnailPath\": \"/thumbnails/highlight-2024-01-15.jpg\",\n  \"fileSize\": 15728640,\n  \"viewCount\": 1251,\n  \"upvoteCount\": 89,\n  \"commentCount\": 12,\n  \"finishCount\": 420,\n  \"totalWatchTime\": 48600,\n  \"isLocked\": false,\n  \"createdAt\": \"2026-01-15T10:00:00.000Z\",\n  \"updatedAt\": \"2026-02-11T15:45:00.000Z\"\n}\n

Side Effect:

View count is incremented fire-and-forget (does not block response):

// Increment view count (fire and forget)\ndb.update(publicMedia)\n  .set({ viewCount: sql`${publicMedia.viewCount} + 1` })\n  .where(eq(publicMedia.id, mediaId))\n  .execute()\n  .catch(err => logger.error({ err }, 'Failed to increment view count'));\n
"},{"location":"v2/backend/modules/media/#post-apimediapublicidupvote","title":"POST /api/media/public/:id/upvote","text":"

Upvote media (session-based, no auth required).

Path Parameters:

Request Body:

{\n  \"sessionId\": \"sess_abc123def456\"\n}\n

Response (200 OK):

{\n  \"success\": true,\n  \"upvoted\": true,\n  \"upvoteCount\": 90\n}\n

Behavior:

Duplicate Prevention:

// Check if already upvoted\nconst [existingVote] = await db\n  .select()\n  .from(upvotes)\n  .where(and(\n    eq(upvotes.mediaId, mediaId),\n    eq(upvotes.sessionId, sessionId)\n  ));\n\nif (existingVote) {\n  return reply.send({ success: true, upvoted: true, upvoteCount: media.upvoteCount });\n}\n
"},{"location":"v2/backend/modules/media/#delete-apimediapublicidupvote","title":"DELETE /api/media/public/:id/upvote","text":"

Remove upvote (session-based).

Path Parameters:

Query Parameters:

Response (200 OK):

{\n  \"success\": true,\n  \"upvoted\": false,\n  \"upvoteCount\": 89\n}\n
"},{"location":"v2/backend/modules/media/#post-apireactions","title":"POST /api/reactions","text":"

Add reaction to video (authenticated users only).

Request Body:

{\n  \"mediaId\": 456,\n  \"reactionType\": \"love\",\n  \"videoTimestamp\": 27\n}\n

Response (200 OK):

{\n  \"success\": true,\n  \"reaction\": {\n    \"id\": 789,\n    \"mediaId\": 456,\n    \"userId\": 123,\n    \"reactionType\": \"love\",\n    \"videoTimestamp\": 27,\n    \"emoji\": \"\u2764\ufe0f\",\n    \"formattedTime\": \"0:27\",\n    \"createdAt\": \"2026-02-11T15:50:00.000Z\"\n  }\n}\n

Validation:

const REACTION_EMOJIS: Record<string, string> = {\n  like: '\ud83d\udc4d',\n  love: '\u2764\ufe0f',\n  laugh: '\ud83d\ude02',\n  wow: '\ud83d\ude2e',\n  sad: '\ud83d\ude22',\n  angry: '\ud83d\ude20',\n};\n\nif (!REACTION_EMOJIS[reactionType]) {\n  return fastify.httpErrors.badRequest('Invalid reaction type');\n}\n

Time Formatting:

function formatVideoTime(seconds: number): string {\n  const h = Math.floor(seconds / 3600);\n  const m = Math.floor((seconds % 3600) / 60);\n  const s = seconds % 60;\n\n  if (h > 0) {\n    return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;\n  }\n  return `${m}:${s.toString().padStart(2, '0')}`;\n}\n\n// Examples:\n// 27 \u2192 \"0:27\"\n// 90 \u2192 \"1:30\"\n// 3661 \u2192 \"1:01:01\"\n
"},{"location":"v2/backend/modules/media/#get-apireactions","title":"GET /api/reactions","text":"

Get reactions (filterable by mediaId/userId).

Query Parameters:

Parameter Type Description mediaId number Filter by media ID userId string Filter by user ID limit number Results per page (default 50)

Example Request:

curl \"http://localhost:4100/api/reactions?mediaId=456&limit=20\"\n

Response (200 OK):

{\n  \"reactions\": [\n    {\n      \"id\": 789,\n      \"mediaId\": 456,\n      \"userId\": 123,\n      \"reactionType\": \"love\",\n      \"videoTimestamp\": 27,\n      \"emoji\": \"\u2764\ufe0f\",\n      \"formattedTime\": \"0:27\",\n      \"createdAt\": \"2026-02-11T15:50:00.000Z\"\n    },\n    {\n      \"id\": 790,\n      \"mediaId\": 456,\n      \"userId\": 124,\n      \"reactionType\": \"laugh\",\n      \"videoTimestamp\": 42,\n      \"emoji\": \"\ud83d\ude02\",\n      \"formattedTime\": \"0:42\",\n      \"createdAt\": \"2026-02-11T15:51:00.000Z\"\n    }\n  ]\n}\n
"},{"location":"v2/backend/modules/media/#get-apireactionsconfig","title":"GET /api/reactions/config","text":"

Get available reaction types.

Example Request:

curl \"http://localhost:4100/api/reactions/config\"\n

Response (200 OK):

{\n  \"reactions\": [\n    { \"type\": \"like\", \"emoji\": \"\ud83d\udc4d\", \"label\": \"Like\" },\n    { \"type\": \"love\", \"emoji\": \"\u2764\ufe0f\", \"label\": \"Love\" },\n    { \"type\": \"laugh\", \"emoji\": \"\ud83d\ude02\", \"label\": \"Laugh\" },\n    { \"type\": \"wow\", \"emoji\": \"\ud83d\ude2e\", \"label\": \"Wow\" },\n    { \"type\": \"sad\", \"emoji\": \"\ud83d\ude22\", \"label\": \"Sad\" },\n    { \"type\": \"angry\", \"emoji\": \"\ud83d\ude20\", \"label\": \"Angry\" }\n  ]\n}\n
"},{"location":"v2/backend/modules/media/#fastify-vs-express-differences","title":"Fastify vs Express Differences","text":"Feature Express API (port 4000) Fastify Media API (port 4100) Framework Express 5 Fastify ORM Prisma Drizzle Schema Validation Zod + middleware Fastify built-in Auth Middleware authenticate, requireRole authenticate, requireAdminRole, optionalAuth Error Handling AppError class + error handler middleware fastify.httpErrors + decorators Route Registration router.get(...) fastify.register(routes, { prefix }) Request Handler (req, res, next) => {} async (request, reply) => {} Database Client import { prisma } import { db } Query Builder Prisma fluent API Drizzle query builder"},{"location":"v2/backend/modules/media/#code-pattern-comparison","title":"Code Pattern Comparison","text":"

Express (Prisma):

import { Router } from 'express';\nimport { prisma } from '../../config/database';\nimport { authenticate, requireRole } from '../../middleware/auth.middleware';\n\nconst router = Router();\n\nrouter.get('/', authenticate, requireRole('ADMIN'), async (req, res, next) => {\n  try {\n    const users = await prisma.user.findMany({\n      where: { role: 'ADMIN' },\n      select: { id: true, email: true },\n    });\n    res.json(users);\n  } catch (err) {\n    next(err);\n  }\n});\n\nexport default router;\n

Fastify (Drizzle):

import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';\nimport { db } from '../db';\nimport { users } from '../db/schema';\nimport { eq } from 'drizzle-orm';\nimport { requireAdminRole } from '../middleware/auth';\n\nexport async function usersRoutes(fastify: FastifyInstance) {\n  fastify.get(\n    '/',\n    { preHandler: requireAdminRole },\n    async (request: FastifyRequest, reply: FastifyReply) => {\n      const results = await db\n        .select({ id: users.id, email: users.email })\n        .from(users)\n        .where(eq(users.role, 'ADMIN'));\n\n      return reply.send(results);\n    }\n  );\n}\n
"},{"location":"v2/backend/modules/media/#frontend-integration","title":"Frontend Integration","text":"

The Media module integrates with multiple frontend pages:

"},{"location":"v2/backend/modules/media/#admin-pages","title":"Admin Pages","text":""},{"location":"v2/backend/modules/media/#public-pages","title":"Public Pages","text":"

State Management:

// Admin: useMediaApi hook\nconst { videos, loading, error } = useMediaApi('/api/videos', {\n  limit: 24,\n  offset: 0,\n  search: '',\n});\n\n// Public: Direct axios calls to media API\nconst { data } = await axios.get('http://localhost:4100/api/media/public', {\n  params: { category: 'highlights', sort: 'popular', limit: 12 },\n});\n
"},{"location":"v2/backend/modules/media/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/backend/modules/media/#denormalized-counters","title":"Denormalized Counters","text":"

The publicMedia table uses denormalized counters for engagement metrics:

viewCount: integer('view_count').default(0),\nupvoteCount: integer('upvote_count').default(0),\ncommentCount: integer('comment_count').default(0),\nfinishCount: integer('finish_count').default(0),\ntotalWatchTime: integer('total_watch_time').default(0),\n

Pros:

Cons:

Mitigation:

"},{"location":"v2/backend/modules/media/#fire-and-forget-view-tracking","title":"Fire-and-Forget View Tracking","text":"

View count increments are fire-and-forget to avoid blocking response:

// Increment view count (fire and forget)\ndb.update(publicMedia)\n  .set({ viewCount: sql`${publicMedia.viewCount} + 1` })\n  .where(eq(publicMedia.id, mediaId))\n  .execute()\n  .catch(err => logger.error({ err }, 'Failed to increment view count'));\n\n// Return immediately (don't await)\nreturn reply.send(media);\n

Trade-off:

"},{"location":"v2/backend/modules/media/#fingerprint-based-deduplication","title":"Fingerprint-Based Deduplication","text":"

The videos table includes a composite index for fast duplicate detection:

fingerprintIdx: index('idx_videos_fingerprint').on(\n  table.durationSeconds, table.fileSize, table.width, table.height\n),\n

Usage:

const duplicates = await db\n  .select()\n  .from(videos)\n  .where(and(\n    eq(videos.durationSeconds, newVideo.durationSeconds),\n    eq(videos.fileSize, newVideo.fileSize),\n    eq(videos.width, newVideo.width),\n    eq(videos.height, newVideo.height),\n  ));\n\nif (duplicates.length > 0 && duplicates[0].fileHash === newVideo.fileHash) {\n  throw new Error('Duplicate video detected');\n}\n

Why Fingerprint Index:

"},{"location":"v2/backend/modules/media/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/media/#media-api-not-starting","title":"Media API Not Starting","text":"

Problem:

Docker logs show \"Media API server closed\" immediately.

Diagnosis:

Check env vars:

docker compose exec api printenv | grep MEDIA\n

Required vars:

MEDIA_API_PORT=4100\nENABLE_MEDIA_FEATURES=true\nMAX_UPLOAD_SIZE_GB=10\n

Solution:

"},{"location":"v2/backend/modules/media/#cors-errors-on-media-api","title":"CORS Errors on Media API","text":"

Problem:

Frontend gets CORS errors when calling media API endpoints.

Diagnosis:

Check CORS origins:

CORS_ORIGINS=http://localhost:3000,http://localhost:3010\n

Behavior:

await fastify.register(cors, {\n  origin: (origin, cb) => {\n    if (!origin) {\n      cb(null, true);  // Allow no origin (mobile, curl)\n      return;\n    }\n\n    if (allowedOrigins.includes(origin)) {\n      cb(null, true);\n    } else {\n      cb(new Error('CORS not allowed'), false);\n    }\n  },\n  credentials: true,\n});\n

Solution:

Add missing origins to CORS_ORIGINS in .env:

CORS_ORIGINS=http://localhost:3000,http://localhost:3010,http://localhost:3100\n
"},{"location":"v2/backend/modules/media/#upvote-not-working","title":"Upvote Not Working","text":"

Problem:

Upvote button doesn't work, returns 400 error.

Diagnosis:

Check request body:

curl -X POST \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"sessionId\":\"sess_abc123\"}' \\\n  http://localhost:4100/api/media/public/456/upvote\n

Common Issues:

  1. Missing sessionId:

    { \"error\": \"sessionId is required\" }\n

  2. Media not found:

    { \"error\": \"Media not found\" }\n

  3. Locked media:

    { \"error\": \"Media is locked\" }\n

Solution:

"},{"location":"v2/backend/modules/media/#reactions-not-appearing","title":"Reactions Not Appearing","text":"

Problem:

Reactions submitted but not appearing in frontend.

Diagnosis:

Check reaction data:

SELECT * FROM video_reactions WHERE \"mediaId\" = 456 ORDER BY \"createdAt\" DESC LIMIT 10;\n

Verify:

Common Issues:

  1. Authentication failed:
  2. Reaction requires auth
  3. Check JWT token in Authorization header

  4. Invalid reaction type:

    { \"error\": \"Invalid reaction type\" }\n

  5. Video not found:

    { \"error\": \"Video not found\" }\n

Solution:

"},{"location":"v2/backend/modules/media/#job-queue-not-processing","title":"Job Queue Not Processing","text":"

Problem:

Jobs stuck in pending status, never transition to running.

Diagnosis:

Check job queue:

SELECT id, type, status, \"resourceCategory\", \"queuePosition\", \"waitingReason\"\nFROM jobs\nWHERE status IN ('pending', 'queued')\nORDER BY priority DESC, \"createdAt\" ASC;\n

Common Issues:

  1. No worker running:
  2. Check if job worker process is running
  3. Verify ENABLE_MEDIA_FEATURES=true

  4. Resource exhaustion:

  5. GPU jobs waiting for VRAM
  6. Check vramRequired vs available VRAM

  7. Pipeline blocking:

  8. Pipeline step depends on previous step completion

Solution:

"},{"location":"v2/backend/modules/media/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/backend/modules/pages/","title":"Pages Module (Landing Page Builder)","text":""},{"location":"v2/backend/modules/pages/#overview","title":"Overview","text":"

The Pages module provides a complete landing page builder with dual editing modes (WYSIWYG GrapesJS + direct HTML), automatic MkDocs export, and reusable block library. It enables admins to create custom landing pages visually or with code, publish them to public URLs (/p/:slug), and optionally export them to the MkDocs documentation site as Material theme overrides.

Key Features:

"},{"location":"v2/backend/modules/pages/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/pages/pages-admin.routes.ts Admin router with 7 endpoints (114 lines) api/src/modules/pages/pages-public.routes.ts Public router (1 endpoint, 21 lines) api/src/modules/pages/blocks.routes.ts Block library router (5 endpoints, 88 lines) api/src/modules/pages/pages.service.ts Landing page business logic + MkDocs export (637 lines) api/src/modules/pages/blocks.service.ts Block CRUD service (89 lines) api/src/modules/pages/pages.schemas.ts Zod validation schemas (83 lines)"},{"location":"v2/backend/modules/pages/#database-models","title":"Database Models","text":"
model LandingPage {\n  id                String            @id @default(cuid())\n  slug              String            @unique\n  title             String\n  description       String?           @db.Text\n  blocks            Json              // JSON from GrapesJS editor\n  htmlOutput        String?           @db.Text\n  cssOutput         String?           @db.Text\n  editorMode        EditorMode        @default(VISUAL)\n  mkdocsPath        String?           // Path in mkdocs/overrides/\n  mkdocsStubPath    String?           // Path to .md stub in mkdocs/docs/\n  mkdocsExportMode  MkdocsExportMode  @default(THEMED)\n  mkdocsHideNav     Boolean           @default(true)\n  mkdocsHideToc     Boolean           @default(true)\n  mkdocsSkipExport  Boolean           @default(false)\n  published         Boolean           @default(false)\n  seoTitle          String?\n  seoDescription    String?           @db.Text\n  seoImage          String?\n  createdAt         DateTime          @default(now())\n  updatedAt         DateTime          @updatedAt\n\n  @@map(\"landing_pages\")\n}\n\nenum EditorMode {\n  VISUAL      // GrapesJS drag-and-drop editor\n  CODE        // Direct HTML editing\n}\n\nenum MkdocsExportMode {\n  THEMED      // Jinja2 extends main.html (Material theme integration)\n  STANDALONE  // Full HTML document (no Jinja2 inheritance)\n}\n\nmodel PageBlock {\n  id           String   @id @default(cuid())\n  type         String   // hero, text, image, cta, features, testimonials, form\n  label        String\n  schema       Json     // Block configuration schema (GrapesJS component definition)\n  defaults     Json     // Default values for new instances\n  thumbnail    String?\n  category     String?\n  sortOrder    Int      @default(0)\n  createdAt    DateTime @default(now())\n  updatedAt    DateTime @updatedAt\n\n  @@map(\"page_blocks\")\n}\n

Key Fields:

Slug Generation:

function generateSlug(title: string): string {\n  return title\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, '-')  // Replace non-alphanumeric with -\n    .replace(/^-+|-+$/g, '')      // Remove leading/trailing -\n    .slice(0, 80);                // Max 80 chars\n}\n

Example Transformations:

"},{"location":"v2/backend/modules/pages/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/pages/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":"Method Path Auth Description GET /api/pages Admin roles List landing pages with pagination/filters GET /api/pages/:id Admin roles Get single landing page POST /api/pages Admin roles Create landing page PUT /api/pages/:id Admin roles Update landing page (triggers MkDocs export) DELETE /api/pages/:id Admin roles Delete landing page (removes MkDocs export) POST /api/pages/sync Admin roles Sync MkDocs overrides to database POST /api/pages/validate Admin roles Validate and repair MkDocs exports

Admin Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

"},{"location":"v2/backend/modules/pages/#block-library-endpoints-admin-only","title":"Block Library Endpoints (Admin Only)","text":"Method Path Auth Description GET /api/page-blocks Admin roles List blocks with category filter GET /api/page-blocks/:id Admin roles Get single block POST /api/page-blocks Admin roles Create block PUT /api/page-blocks/:id Admin roles Update block DELETE /api/page-blocks/:id Admin roles Delete block"},{"location":"v2/backend/modules/pages/#public-endpoints-no-authentication","title":"Public Endpoints (No Authentication)","text":"Method Path Auth Description GET /api/pages/:slug/view None Get published page by slug"},{"location":"v2/backend/modules/pages/#admin-endpoint-details","title":"Admin Endpoint Details","text":""},{"location":"v2/backend/modules/pages/#get-apipages","title":"GET /api/pages","text":"

List landing pages with pagination, search, and filtering.

Query Parameters:

Parameter Type Required Default Description page number No 1 Page number limit number No 20 Results per page (max 100) search string No - Search title, description, or slug published enum No - Filter by status: 'true', 'false'

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/pages?page=1&limit=10&published=true&search=about\"\n

Response (200 OK):

{\n  \"pages\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"slug\": \"about-us\",\n      \"title\": \"About Us\",\n      \"description\": \"Learn about our organization\",\n      \"editorMode\": \"VISUAL\",\n      \"blocks\": {\n        \"assets\": [],\n        \"pages\": [/* GrapesJS page structure */],\n        \"styles\": [/* GrapesJS styles */]\n      },\n      \"htmlOutput\": \"<div class=\\\"hero\\\">...</div>\",\n      \"cssOutput\": \".hero { background: #3498db; }\",\n      \"mkdocsPath\": \"about-us.html\",\n      \"mkdocsStubPath\": \"about-us.md\",\n      \"mkdocsExportMode\": \"THEMED\",\n      \"mkdocsHideNav\": true,\n      \"mkdocsHideToc\": true,\n      \"mkdocsSkipExport\": false,\n      \"published\": true,\n      \"seoTitle\": \"About Us \u2014 Changemaker Lite\",\n      \"seoDescription\": \"Learn about our mission and values\",\n      \"seoImage\": \"https://example.com/og-image.jpg\",\n      \"createdAt\": \"2026-02-01T12:00:00.000Z\",\n      \"updatedAt\": \"2026-02-11T14:30:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 10,\n    \"total\": 5,\n    \"totalPages\": 1\n  }\n}\n

Search Behavior:

if (search) {\n  where.OR = [\n    { title: { contains: search, mode: 'insensitive' } },\n    { description: { contains: search, mode: 'insensitive' } },\n    { slug: { contains: search, mode: 'insensitive' } },\n  ];\n}\n
"},{"location":"v2/backend/modules/pages/#get-apipagesid","title":"GET /api/pages/:id","text":"

Get single landing page with full editor state.

Path Parameters:

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/pages/clx1234567890\"\n

Response (200 OK):

Returns full landing page object (same format as GET list).

Error Responses:

"},{"location":"v2/backend/modules/pages/#post-apipages","title":"POST /api/pages","text":"

Create landing page with auto-generated slug.

Request Body:

{\n  \"title\": \"About Us\",\n  \"description\": \"Learn about our organization\",\n  \"editorMode\": \"VISUAL\",\n  \"blocks\": {},\n  \"htmlOutput\": null,\n  \"cssOutput\": null,\n  \"mkdocsExportMode\": \"THEMED\",\n  \"mkdocsHideNav\": true,\n  \"mkdocsHideToc\": true,\n  \"published\": false,\n  \"seoTitle\": \"About Us \u2014 Changemaker Lite\",\n  \"seoDescription\": \"Learn about our mission and values\",\n  \"seoImage\": \"https://example.com/og-image.jpg\"\n}\n

Response (201 Created):

Returns created landing page object.

Auto-Generated Fields:

Validation:

"},{"location":"v2/backend/modules/pages/#put-apipagesid","title":"PUT /api/pages/:id","text":"

Update landing page. Triggers MkDocs export if published.

Request Body (Partial):

{\n  \"htmlOutput\": \"<div class=\\\"hero\\\">Updated content</div>\",\n  \"cssOutput\": \".hero { background: #e74c3c; }\",\n  \"published\": true\n}\n

Response (200 OK):

Returns updated landing page object.

Side Effects:

  1. Slug regeneration if title changes (preserves old slug if collision):

    if (data.title && data.title !== existing.title) {\n  const baseSlug = generateSlug(data.title);\n  const newSlug = await resolveSlugCollision(baseSlug, id);\n  updateData.slug = newSlug;\n\n  // Update mkdocsPath if auto-generated\n  if (existing.mkdocsPath === `${existing.slug}.html`) {\n    updateData.mkdocsPath = `${newSlug}.html`;\n    await removeFromMkDocs(existing.mkdocsPath, existing.mkdocsStubPath);\n  }\n}\n

  2. MkDocs export if published === true && mkdocsSkipExport === false:

    if (page.published && !page.mkdocsSkipExport && page.mkdocsPath && page.htmlOutput) {\n  const stubPath = await exportToMkDocs({\n    mkdocsPath: page.mkdocsPath,\n    html: page.htmlOutput,\n    css: page.cssOutput,\n    editorMode: page.editorMode,\n    exportMode: page.mkdocsExportMode,\n    title: page.title,\n    seoTitle: page.seoTitle,\n    seoDescription: page.seoDescription,\n    hideNav: page.mkdocsHideNav,\n    hideToc: page.mkdocsHideToc,\n  });\n}\n

  3. MkDocs cleanup if published === false || mkdocsSkipExport === true:

    await removeFromMkDocs(existing.mkdocsPath, existing.mkdocsStubPath);\n

Export Workflow:

graph TD\n    A[Update Landing Page] --> B{Published?}\n    B -->|No| C[Remove MkDocs Export]\n    B -->|Yes| D{Skip Export?}\n    D -->|Yes| C\n    D -->|No| E{Has HTML Output?}\n    E -->|No| F[No Action]\n    E -->|Yes| G[Export to MkDocs]\n    G --> H[Write Override HTML]\n    H --> I[Write .md Stub]\n    I --> J[Update stubPath in DB]
"},{"location":"v2/backend/modules/pages/#delete-apipagesid","title":"DELETE /api/pages/:id","text":"

Delete landing page and remove MkDocs export.

Path Parameters:

Response (204 No Content):

No response body.

Side Effects:

"},{"location":"v2/backend/modules/pages/#post-apipagessync","title":"POST /api/pages/sync","text":"

Sync MkDocs override files to database (import untracked files, update CODE pages).

Example Request:

curl -X POST \\\n  -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/pages/sync\"\n

Response (200 OK):

{\n  \"imported\": 2,\n  \"updated\": 1,\n  \"stubs\": 3\n}\n

Behavior:

  1. Scan mkdocs/overrides/ directory for .html files:

    const files = await scanOverrideFiles(MKDOCS_OVERRIDES);\n// Returns: [{ relativePath: 'foo.html', fullPath: '/full/path/foo.html' }, ...]\n

  2. Import untracked files as CODE pages:

    if (!tracked) {\n  // New file not in database\n  const title = path.basename(file.relativePath, '.html');\n  const baseSlug = generateSlug(title);\n  const slug = await resolveSlugCollision(baseSlug);\n\n  await prisma.landingPage.create({\n    data: {\n      slug,\n      title,\n      editorMode: 'CODE',\n      htmlOutput: content,\n      mkdocsPath: file.relativePath,\n      published: true,\n      blocks: {},\n    },\n  });\n\n  imported++;\n}\n

  3. Update CODE pages from disk (disk wins):

    else if (tracked.editorMode === 'CODE') {\n  // Tracked CODE page \u2014 sync from disk\n  await prisma.landingPage.update({\n    where: { id: tracked.id },\n    data: { htmlOutput: content },\n  });\n\n  updated++;\n}\n// VISUAL pages: don't overwrite from disk (managed by GrapesJS)\n

  4. Backfill missing .md stubs for published pages:

    for (const page of existingPages) {\n  if (!page.published || !page.mkdocsPath) continue;\n\n  const expectedStubPath = page.mkdocsStubPath || deriveStubPath(page.mkdocsPath);\n  const exists = await stubExistsOnDisk(expectedStubPath);\n  if (!exists) {\n    await writeStubFile(expectedStubPath, stubContent);\n    stubs++;\n  }\n}\n

Use Cases:

"},{"location":"v2/backend/modules/pages/#post-apipagesvalidate","title":"POST /api/pages/validate","text":"

Validate MkDocs exports and repair missing files.

Example Request:

curl -X POST \\\n  -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/pages/validate\"\n

Response (200 OK):

{\n  \"validated\": 10,\n  \"repaired\": 2,\n  \"errors\": [\n    {\n      \"pageId\": \"clx1234567890\",\n      \"slug\": \"broken-page\",\n      \"error\": \"ENOENT: no such file or directory\"\n    }\n  ]\n}\n

Behavior:

  1. Query all published pages with mkdocsSkipExport === false:

    const pages = await prisma.landingPage.findMany({\n  where: {\n    published: true,\n    mkdocsSkipExport: false,\n    mkdocsPath: { not: null },\n    htmlOutput: { not: null },\n  },\n});\n

  2. Check override HTML exists:

    const overridePath = path.join(MKDOCS_OVERRIDES, page.mkdocsPath);\nawait fs.access(overridePath);  // Throws if missing\n

  3. Check .md stub exists:

    const expectedStubPath = page.mkdocsStubPath || deriveStubPath(page.mkdocsPath);\nconst stubExists = await stubExistsOnDisk(expectedStubPath);\n

  4. Repair if either missing:

    if (!overrideExists || !stubExists) {\n  await exportToMkDocs({\n    mkdocsPath: page.mkdocsPath,\n    html: page.htmlOutput,\n    css: page.cssOutput,\n    // ...\n  });\n\n  repaired++;\n}\n

Use Cases:

"},{"location":"v2/backend/modules/pages/#block-library-endpoint-details","title":"Block Library Endpoint Details","text":""},{"location":"v2/backend/modules/pages/#get-apipage-blocks","title":"GET /api/page-blocks","text":"

List blocks with optional category filter.

Query Parameters:

Parameter Type Required Description category string No Filter by category

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/page-blocks?category=hero\"\n

Response (200 OK):

[\n  {\n    \"id\": \"clx1234567890\",\n    \"type\": \"hero\",\n    \"label\": \"Hero Section\",\n    \"schema\": {\n      \"type\": \"div\",\n      \"classes\": [\"hero\"],\n      \"attributes\": { \"data-gjs-type\": \"hero\" },\n      \"components\": [/* ... */],\n      \"traits\": [\n        { \"type\": \"text\", \"name\": \"heading\", \"label\": \"Heading\" },\n        { \"type\": \"text\", \"name\": \"subheading\", \"label\": \"Subheading\" }\n      ]\n    },\n    \"defaults\": {\n      \"heading\": \"Welcome to our site\",\n      \"subheading\": \"Your journey starts here\"\n    },\n    \"thumbnail\": \"https://example.com/hero-thumb.jpg\",\n    \"category\": \"hero\",\n    \"sortOrder\": 1,\n    \"createdAt\": \"2026-01-15T12:00:00.000Z\",\n    \"updatedAt\": \"2026-01-20T10:00:00.000Z\"\n  }\n]\n

Sort Order:

Blocks are sorted by sortOrder ASC. Lower numbers appear first in block library panel.

"},{"location":"v2/backend/modules/pages/#post-apipage-blocks","title":"POST /api/page-blocks","text":"

Create block.

Request Body:

{\n  \"type\": \"hero\",\n  \"label\": \"Hero Section\",\n  \"schema\": {\n    \"type\": \"div\",\n    \"classes\": [\"hero\"],\n    \"attributes\": { \"data-gjs-type\": \"hero\" }\n  },\n  \"defaults\": {\n    \"heading\": \"Welcome\",\n    \"subheading\": \"Your journey starts here\"\n  },\n  \"thumbnail\": \"https://example.com/hero-thumb.jpg\",\n  \"category\": \"hero\",\n  \"sortOrder\": 1\n}\n

Response (201 Created):

Returns created block object.

"},{"location":"v2/backend/modules/pages/#public-endpoint-details","title":"Public Endpoint Details","text":""},{"location":"v2/backend/modules/pages/#get-apipagesslugview","title":"GET /api/pages/:slug/view","text":"

Get published landing page by slug (no auth required).

Path Parameters:

Example Request:

curl http://api.cmlite.org/api/pages/about-us/view\n

Response (200 OK):

Returns full landing page object (same format as admin GET).

Filtering:

Error Responses:

"},{"location":"v2/backend/modules/pages/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/pages/#pagesservicefindallfilters","title":"pagesService.findAll(filters)","text":"

List landing pages with pagination, search, and filtering.

Usage:

import { pagesService } from './pages.service';\n\nconst result = await pagesService.findAll({\n  page: 1,\n  limit: 20,\n  search: 'about',\n  published: 'true',\n});\n\nconsole.log(result.pages.length);    // Array of pages\nconsole.log(result.pagination);      // { page, limit, total, totalPages }\n
"},{"location":"v2/backend/modules/pages/#pagesservicecreatedata","title":"pagesService.create(data)","text":"

Create landing page with auto-generated slug and mkdocsPath.

Usage:

const page = await pagesService.create({\n  title: 'About Us',\n  description: 'Learn about our organization',\n  editorMode: 'VISUAL',\n  blocks: {},\n  published: false,\n});\n\nconsole.log(page.slug);          // 'about-us'\nconsole.log(page.mkdocsPath);    // 'about-us.html'\n
"},{"location":"v2/backend/modules/pages/#pagesserviceupdateid-data","title":"pagesService.update(id, data)","text":"

Update landing page with MkDocs export/cleanup side effects.

Usage:

const page = await pagesService.update('clx1234567890', {\n  htmlOutput: '<div class=\"hero\">Updated</div>',\n  cssOutput: '.hero { background: #e74c3c; }',\n  published: true,\n});\n\n// Side effect: Exports to mkdocs/overrides/{mkdocsPath}\n// Side effect: Creates .md stub in mkdocs/docs/{mkdocsStubPath}\n

Export Trigger:

"},{"location":"v2/backend/modules/pages/#pagesservicesyncoverrides","title":"pagesService.syncOverrides()","text":"

Sync MkDocs override files to database.

Usage:

const result = await pagesService.syncOverrides();\n\nconsole.log(`Imported: ${result.imported}`);  // New CODE pages imported\nconsole.log(`Updated: ${result.updated}`);    // CODE pages synced from disk\nconsole.log(`Stubs: ${result.stubs}`);        // Missing stubs created\n

Workflow:

  1. Scan mkdocs/overrides/ for .html files
  2. Import untracked files as CODE pages
  3. Update tracked CODE pages from disk (disk wins)
  4. Don't overwrite VISUAL pages (managed by GrapesJS)
  5. Backfill missing .md stubs
"},{"location":"v2/backend/modules/pages/#pagesservicevalidateexports","title":"pagesService.validateExports()","text":"

Validate and repair MkDocs exports.

Usage:

const result = await pagesService.validateExports();\n\nconsole.log(`Validated: ${result.validated}`);  // Pages checked\nconsole.log(`Repaired: ${result.repaired}`);    // Missing exports repaired\nconsole.log(`Errors: ${result.errors.length}`);  // Failed repairs\n

Repair Logic:

// Check override HTML exists\nconst overridePath = path.join(MKDOCS_OVERRIDES, page.mkdocsPath);\nconst overrideExists = await fs.access(overridePath).then(() => true, () => false);\n\n// Check stub exists\nconst stubExists = await stubExistsOnDisk(expectedStubPath);\n\n// Repair if either missing\nif (!overrideExists || !stubExists) {\n  await exportToMkDocs({/* ... */});\n  repaired++;\n}\n
"},{"location":"v2/backend/modules/pages/#mkdocs-export-system","title":"MkDocs Export System","text":""},{"location":"v2/backend/modules/pages/#export-modes","title":"Export Modes","text":"

1. THEMED (Default)

Wraps HTML in Jinja2 template extending MkDocs Material theme:

{% extends \"main.html\" %}\n{% block content %}\n<style>\n{{ css }}\n</style>\n{{ html }}\n{% endblock %}\n

Pros:

Cons:

2. STANDALONE

Full HTML document without Jinja2 inheritance:

<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>{{ seoTitle || title }}</title>\n    <meta name=\"description\" content=\"{{ seoDescription }}\">\n    <style>\n{{ css }}\n    </style>\n</head>\n<body>\n{{ html }}\n</body>\n</html>\n

Pros:

Cons:

"},{"location":"v2/backend/modules/pages/#md-stub-file-format","title":".md Stub File Format","text":"

The .md stub file is required for MkDocs to recognize the override template. It uses Material theme front matter to configure page appearance.

Example:

---\ntemplate: about-us.html\nhide:\n  - navigation\n  - toc\ntitle: \"About Us \u2014 Changemaker Lite\"\ndescription: \"Learn about our mission and values\"\n---\n

Front Matter Fields:

Generation:

function generateMdStub(opts: StubOptions): string {\n  const hideItems: string[] = [];\n  if (opts.hideNav) hideItems.push('  - navigation');\n  if (opts.hideToc) hideItems.push('  - toc');\n\n  const hideBlock = hideItems.length > 0 ? `hide:\\n${hideItems.join('\\n')}\\n` : '';\n  const descLine = opts.description ? `description: \"${opts.description.replace(/\"/g, '\\\\\"')}\"\\n` : '';\n\n  return `---\ntemplate: ${opts.overrideFilename}\n${hideBlock}title: \"${opts.title.replace(/\"/g, '\\\\\"')}\"\n${descLine}---\n`;\n}\n
"},{"location":"v2/backend/modules/pages/#path-validation","title":"Path Validation","text":"

All mkdocsPath values are validated to prevent path traversal attacks:

function validateMkdocsPath(mkdocsPath: string): void {\n  // Check for null bytes\n  if (mkdocsPath.includes('\\0')) {\n    throw new AppError(400, 'Invalid path: null byte detected', 'INVALID_MKDOCS_PATH');\n  }\n\n  // Normalize and check for traversal\n  const normalized = path.normalize(mkdocsPath);\n  if (normalized.includes('..') || path.isAbsolute(normalized)) {\n    throw new AppError(400, 'Path traversal not allowed', 'INVALID_MKDOCS_PATH');\n  }\n\n  // Check for encoded traversal sequences\n  if (mkdocsPath.includes('%2e') || mkdocsPath.includes('%2E')) {\n    throw new AppError(400, 'Encoded path traversal not allowed', 'INVALID_MKDOCS_PATH');\n  }\n\n  if (!mkdocsPath.endsWith('.html')) {\n    throw new AppError(400, 'Path must end with .html', 'INVALID_MKDOCS_PATH');\n  }\n}\n

Blocked Patterns:

"},{"location":"v2/backend/modules/pages/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/pages/#create-landing-page-schema","title":"Create Landing Page Schema","text":"
export const createLandingPageSchema = z.object({\n  title: z.string().min(1, 'Title is required'),\n  description: z.string().optional(),\n  editorMode: z.enum(['VISUAL', 'CODE']).optional().default('VISUAL'),\n  blocks: z.any().optional().default({}),\n  htmlOutput: z.string().optional(),\n  cssOutput: z.string().optional(),\n  mkdocsPath: z.string().optional(),\n  mkdocsExportMode: z.enum(['THEMED', 'STANDALONE']).optional().default('THEMED'),\n  mkdocsHideNav: z.boolean().optional().default(true),\n  mkdocsHideToc: z.boolean().optional().default(true),\n  mkdocsSkipExport: z.boolean().optional().default(false),\n  published: z.boolean().optional().default(false),\n  seoTitle: z.string().optional(),\n  seoDescription: z.string().optional(),\n  seoImage: z.string().optional(),\n});\n

Defaults:

"},{"location":"v2/backend/modules/pages/#create-page-block-schema","title":"Create Page Block Schema","text":"
export const createPageBlockSchema = z.object({\n  type: z.string().min(1, 'Type is required'),\n  label: z.string().min(1, 'Label is required'),\n  schema: z.any().optional().default({}),\n  defaults: z.any().optional().default({}),\n  thumbnail: z.string().optional(),\n  category: z.string().optional(),\n  sortOrder: z.number().int().optional().default(0),\n});\n

Example Valid Input:

{\n  \"type\": \"hero\",\n  \"label\": \"Hero Section\",\n  \"schema\": {\n    \"type\": \"div\",\n    \"classes\": [\"hero\"]\n  },\n  \"defaults\": {\n    \"heading\": \"Welcome\"\n  },\n  \"category\": \"hero\",\n  \"sortOrder\": 1\n}\n
"},{"location":"v2/backend/modules/pages/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/pages/#admin-create-landing-page","title":"Admin: Create Landing Page","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst createPage = async () => {\n  try {\n    const { data } = await api.post('/api/pages', {\n      title: 'About Us',\n      description: 'Learn about our organization',\n      editorMode: 'VISUAL',\n      mkdocsExportMode: 'THEMED',\n      mkdocsHideNav: true,\n      mkdocsHideToc: true,\n      published: false,\n      seoTitle: 'About Us \u2014 Changemaker Lite',\n      seoDescription: 'Learn about our mission and values',\n    });\n\n    message.success(`Page created: ${data.slug}`);\n    return data;\n  } catch (error) {\n    message.error('Failed to create page');\n    throw error;\n  }\n};\n
"},{"location":"v2/backend/modules/pages/#admin-publish-page-triggers-mkdocs-export","title":"Admin: Publish Page (Triggers MkDocs Export)","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst publishPage = async (pageId: string, htmlOutput: string, cssOutput: string) => {\n  try {\n    const { data } = await api.put(`/api/pages/${pageId}`, {\n      htmlOutput,\n      cssOutput,\n      published: true,\n    });\n\n    message.success(`Page published and exported to MkDocs!`);\n    return data;\n  } catch (error) {\n    message.error('Failed to publish page');\n    throw error;\n  }\n};\n
"},{"location":"v2/backend/modules/pages/#admin-sync-mkdocs-overrides","title":"Admin: Sync MkDocs Overrides","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst syncOverrides = async () => {\n  try {\n    const { data } = await api.post('/api/pages/sync');\n\n    message.success(\n      `Sync complete: ${data.imported} imported, ${data.updated} updated, ${data.stubs} stubs created`\n    );\n    return data;\n  } catch (error) {\n    message.error('Failed to sync overrides');\n    throw error;\n  }\n};\n
"},{"location":"v2/backend/modules/pages/#admin-validate-and-repair-exports","title":"Admin: Validate and Repair Exports","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst validateExports = async () => {\n  try {\n    const { data } = await api.post('/api/pages/validate');\n\n    if (data.errors.length > 0) {\n      message.warning(`Validation complete: ${data.repaired} repaired, ${data.errors.length} errors`);\n    } else {\n      message.success(`Validation complete: ${data.validated} validated, ${data.repaired} repaired`);\n    }\n\n    return data;\n  } catch (error) {\n    message.error('Failed to validate exports');\n    throw error;\n  }\n};\n
"},{"location":"v2/backend/modules/pages/#public-render-landing-page","title":"Public: Render Landing Page","text":"
import axios from 'axios';\nimport { useParams } from 'react-router-dom';\nimport { useEffect, useState } from 'react';\n\ninterface LandingPage {\n  id: string;\n  slug: string;\n  title: string;\n  htmlOutput: string;\n  cssOutput: string | null;\n  seoTitle: string | null;\n  seoDescription: string | null;\n}\n\nconst LandingPageRenderer = () => {\n  const { slug } = useParams<{ slug: string }>();\n  const [page, setPage] = useState<LandingPage | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  useEffect(() => {\n    const fetchPage = async () => {\n      try {\n        const { data } = await axios.get(`/api/pages/${slug}/view`);\n        setPage(data);\n      } catch (error) {\n        console.error('Page not found:', error);\n      } finally {\n        setLoading(false);\n      }\n    };\n\n    fetchPage();\n  }, [slug]);\n\n  if (loading) return <div>Loading...</div>;\n  if (!page) return <div>Page not found</div>;\n\n  return (\n    <>\n      {/* Inject CSS */}\n      {page.cssOutput && <style dangerouslySetInnerHTML={{ __html: page.cssOutput }} />}\n\n      {/* Render HTML */}\n      <div dangerouslySetInnerHTML={{ __html: page.htmlOutput }} />\n    </>\n  );\n};\n
"},{"location":"v2/backend/modules/pages/#frontend-integration","title":"Frontend Integration","text":"

The LandingPagesPage component (admin/src/pages/LandingPagesPage.tsx) provides:

State Management:

const [pages, setPages] = useState<LandingPage[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });\nconst [filters, setFilters] = useState({ search: '', published: null });\nconst [settingsModalOpen, setSettingsModalOpen] = useState(false);\n

Page Editor:

The PageEditorPage component (admin/src/pages/PageEditorPage.tsx) provides:

Public Renderer:

The LandingPage component (admin/src/pages/public/LandingPage.tsx) provides:

"},{"location":"v2/backend/modules/pages/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/backend/modules/pages/#mkdocs-export-caching","title":"MkDocs Export Caching","text":"

MkDocs exports are triggered on update, not on every GET request. This avoids I/O overhead.

Export Trigger:

if (page.published && !page.mkdocsSkipExport && page.mkdocsPath && page.htmlOutput) {\n  await exportToMkDocs({/* ... */});\n}\n

No Export:

"},{"location":"v2/backend/modules/pages/#slug-collision-handling","title":"Slug Collision Handling","text":"

The slug collision resolver loops until unique slug found. To avoid infinite loops, it uses suffix counter:

async function resolveSlugCollision(slug: string, excludeId?: string): Promise<string> {\n  let candidate = slug;\n  let suffix = 2;\n\n  while (true) {\n    const existing = await prisma.landingPage.findUnique({ where: { slug: candidate } });\n    if (!existing || (excludeId && existing.id === excludeId)) {\n      return candidate;\n    }\n    candidate = `${slug}-${suffix}`;  // about-us-2, about-us-3, ...\n    suffix++;\n  }\n}\n

Worst-case:

"},{"location":"v2/backend/modules/pages/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/pages/#mkdocs-override-not-appearing","title":"MkDocs Override Not Appearing","text":"

Problem:

Page is published but doesn't appear on MkDocs site.

Diagnosis:

  1. Check override file exists:

    ls mkdocs/overrides/about-us.html\n

  2. Check stub file exists:

    ls mkdocs/docs/about-us.md\n

  3. Check stub front matter:

    cat mkdocs/docs/about-us.md\n

Verify template: points to override filename (not path):

template: about-us.html  # Correct\ntemplate: overrides/about-us.html  # WRONG \u2014 causes TemplateNotFound\n

  1. Check MkDocs logs:
    docker compose logs -f mkdocs\n

Solutions:

"},{"location":"v2/backend/modules/pages/#path-traversal-validation-error","title":"Path Traversal Validation Error","text":"

Problem:

Creating page fails with \"Path traversal not allowed\" error.

Diagnosis:

Check mkdocsPath value for blocked patterns:

// Blocked:\nmkdocsPath: '../etc/passwd.html'       // Path traversal\nmkdocsPath: '/etc/passwd.html'         // Absolute path\nmkdocsPath: '%2e%2e/etc/passwd.html'   // Encoded traversal\nmkdocsPath: 'foo\\0bar.html'            // Null byte\n\n// Allowed:\nmkdocsPath: 'about-us.html'            // Simple filename\nmkdocsPath: 'subfolder/about-us.html'  // Subdirectory (no traversal)\n

Solution:

Use safe filenames without path traversal sequences. Subfolders are allowed but must not contain ...

"},{"location":"v2/backend/modules/pages/#code-page-overwritten-by-disk","title":"CODE Page Overwritten by Disk","text":"

Problem:

Manual edits to CODE page in database are lost after sync.

Diagnosis:

Check editorMode:

SELECT id, slug, \"editorMode\" FROM landing_pages WHERE slug = 'my-page';\n

Behavior:

Solution:

"},{"location":"v2/backend/modules/pages/#stub-template-not-found","title":"Stub Template Not Found","text":"

Problem:

MkDocs build fails with TemplateNotFound error.

Diagnosis:

Check stub front matter:

cat mkdocs/docs/about-us.md\n

Common Mistakes:

# WRONG \u2014 includes directory path\ntemplate: overrides/about-us.html\n\n# CORRECT \u2014 filename only\ntemplate: about-us.html\n

Why:

MkDocs Material template: searches in custom_dir (which includes /overrides). Using overrides/ in the template value causes it to look for overrides/overrides/about-us.html.

Solution:

Re-export page to fix stub:

curl -X POST -H \"Authorization: Bearer <token>\" \\\n  http://api.cmlite.org/api/pages/validate\n
"},{"location":"v2/backend/modules/pages/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/backend/modules/representatives/","title":"Representatives Module","text":""},{"location":"v2/backend/modules/representatives/#overview","title":"Overview","text":"

The Representatives module integrates with the Canadian Represent API to provide elected official lookup by postal code. It features intelligent caching, rate limiting, deduplication, and both public and admin endpoints for managing representative data.

Key Features:

"},{"location":"v2/backend/modules/representatives/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/influence/representatives/representatives.routes.ts Router with 8 endpoints (2 public, 6 admin) api/src/modules/influence/representatives/representatives.service.ts Representative business logic + Represent API integration api/src/modules/influence/representatives/representatives.schemas.ts Zod validation schemas api/src/modules/influence/representatives/represent-api.client.ts Represent API HTTP client with rate limiting"},{"location":"v2/backend/modules/representatives/#database-model","title":"Database Model","text":"
model Representative {\n  id                    String    @id @default(cuid())\n  postalCode            String\n  name                  String?\n  email                 String?\n  districtName          String?\n  electedOffice         String?\n  partyName             String?\n  representativeSetName String?\n  url                   String?\n  photoUrl              String?\n  offices               Json?     // JSON array of office contact info\n  cachedAt              DateTime  @default(now())\n\n  @@index([postalCode])\n  @@map(\"representatives\")\n}\n

Field Descriptions:

"},{"location":"v2/backend/modules/representatives/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/representatives/#public-endpoints-no-authentication","title":"Public Endpoints (No Authentication)","text":"Method Path Description GET /api/representatives/by-postal/:postalCode Lookup representatives by postal code (cache-first) GET /api/representatives/test-connection Test Represent API connectivity"},{"location":"v2/backend/modules/representatives/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":"Method Path Auth Description GET /api/representatives/cache-stats Admin roles Get cache statistics GET /api/representatives Admin roles List all cached representatives (paginated) GET /api/representatives/:id Admin roles Get single cached representative DELETE /api/representatives/by-postal/:postalCode Admin roles Clear cache for postal code DELETE /api/representatives/:id Admin roles Delete single cached representative

Admin Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

"},{"location":"v2/backend/modules/representatives/#public-endpoint-details","title":"Public Endpoint Details","text":""},{"location":"v2/backend/modules/representatives/#get-apirepresentativesby-postalpostalcode","title":"GET /api/representatives/by-postal/:postalCode","text":"

Lookup representatives by Canadian postal code. Uses cache-first strategy: returns cached results if available, otherwise calls Represent API and caches results asynchronously.

Path Parameters:

Query Parameters:

Parameter Type Required Default Description refresh boolean No false Force API call even if cached data exists

Example Request:

# Cache-first lookup\ncurl \"http://api.cmlite.org/api/representatives/by-postal/M5H2N2\"\n\n# Force refresh from API\ncurl \"http://api.cmlite.org/api/representatives/by-postal/M5H2N2?refresh=true\"\n

Response (200 OK):

{\n  \"source\": \"cache\",\n  \"postalCode\": \"M5H2N2\",\n  \"location\": {\n    \"city\": \"Toronto\",\n    \"province\": \"ON\"\n  },\n  \"representatives\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"postalCode\": \"M5H2N2\",\n      \"name\": \"Chrystia Freeland\",\n      \"email\": \"chrystia.freeland@parl.gc.ca\",\n      \"districtName\": \"University\u2014Rosedale\",\n      \"electedOffice\": \"MP\",\n      \"partyName\": \"Liberal\",\n      \"representativeSetName\": \"House of Commons\",\n      \"url\": \"https://www.ourcommons.ca/members/en/chrystia-freeland(71619)\",\n      \"photoUrl\": \"https://www.ourcommons.ca/Content/Parliamentarians/Images/OfficialMPPhotos/44/FreelendeC_Lib.jpg\",\n      \"offices\": [\n        {\n          \"type\": \"constituency\",\n          \"tel\": \"416-656-2424\",\n          \"fax\": \"416-656-2425\",\n          \"postal\": \"703-2005 Sheppard Ave E, Toronto ON M2J 5B4\"\n        }\n      ],\n      \"cachedAt\": \"2026-02-11T12:00:00.000Z\"\n    },\n    {\n      \"id\": \"clx0987654321\",\n      \"postalCode\": \"M5H2N2\",\n      \"name\": \"Suze Morrison\",\n      \"email\": \"smorrisons@ola.org\",\n      \"districtName\": \"Toronto Centre\",\n      \"electedOffice\": \"MPP\",\n      \"partyName\": \"NDP\",\n      \"representativeSetName\": \"Legislative Assembly of Ontario\",\n      \"url\": \"https://www.ola.org/en/members/all/suze-morrison\",\n      \"photoUrl\": null,\n      \"offices\": [],\n      \"cachedAt\": \"2026-02-11T12:00:00.000Z\"\n    }\n  ]\n}\n

Response Fields:

Error Responses:

Caching Strategy:

// 1. Check cache first (unless forceRefresh)\nconst cached = await prisma.representative.findMany({ where: { postalCode: code } });\nif (cached.length > 0 && !forceRefresh) {\n  return { source: 'cache', representatives: cached };\n}\n\n// 2. Call Represent API\nconst apiResponse = await representApiClient.getByPostalCode(code);\n\n// 3. Fire-and-forget cache write (don't await)\ncacheWrite(); // Deletes old cache, creates new entries\n\n// 4. Return API results immediately (don't wait for cache)\nreturn { source: 'api', representatives: uniqueReps };\n

Deduplication:

Representatives from both representatives_centroid and representatives_concordance are merged and deduplicated by name|elected_office key to avoid duplicate entries.

function deduplicateReps(reps: RepresentRepresentative[]): RepresentRepresentative[] {\n  const seen = new Set<string>();\n  return reps.filter((rep) => {\n    const key = `${rep.name}|${rep.elected_office}`;\n    if (seen.has(key)) return false;\n    seen.add(key);\n    return true;\n  });\n}\n
"},{"location":"v2/backend/modules/representatives/#get-apirepresentativestest-connection","title":"GET /api/representatives/test-connection","text":"

Test connectivity to the Represent API.

Example Request:

curl \"http://api.cmlite.org/api/representatives/test-connection\"\n

Response (200 OK):

{\n  \"ok\": true,\n  \"message\": \"Represent API is reachable\"\n}\n

Response (200 OK, API Down):

{\n  \"ok\": false,\n  \"message\": \"HTTP 503\"\n}\n

Use Cases:

"},{"location":"v2/backend/modules/representatives/#admin-endpoint-details","title":"Admin Endpoint Details","text":""},{"location":"v2/backend/modules/representatives/#get-apirepresentativescache-stats","title":"GET /api/representatives/cache-stats","text":"

Get cache statistics for the representatives cache.

Authentication: Required (Admin roles)

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/representatives/cache-stats\"\n

Response (200 OK):

{\n  \"totalRepresentatives\": 1247,\n  \"postalCodesWithRepresentatives\": 412,\n  \"totalPostalCodes\": 450\n}\n

Field Descriptions:

"},{"location":"v2/backend/modules/representatives/#get-apirepresentatives","title":"GET /api/representatives","text":"

List all cached representatives with pagination and search.

Authentication: Required (Admin roles)

Query Parameters:

Parameter Type Required Default Description page number No 1 Page number limit number No 20 Results per page (max 100) search string No - Search name, email, district, or office postalCode string No - Filter by postal code

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/representatives?page=1&limit=10&search=Toronto&postalCode=M5H2N2\"\n

Response (200 OK):

{\n  \"representatives\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"postalCode\": \"M5H2N2\",\n      \"name\": \"Chrystia Freeland\",\n      \"email\": \"chrystia.freeland@parl.gc.ca\",\n      \"districtName\": \"University\u2014Rosedale\",\n      \"electedOffice\": \"MP\",\n      \"partyName\": \"Liberal\",\n      \"representativeSetName\": \"House of Commons\",\n      \"url\": \"https://www.ourcommons.ca/members/en/chrystia-freeland(71619)\",\n      \"photoUrl\": \"https://www.ourcommons.ca/Content/Parliamentarians/Images/OfficialMPPhotos/44/FreelendeC_Lib.jpg\",\n      \"offices\": [...],\n      \"cachedAt\": \"2026-02-11T12:00:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 10,\n    \"total\": 15,\n    \"totalPages\": 2\n  }\n}\n

Search Logic:

Search term is matched against name, email, district name, or elected office (case-insensitive):

if (search) {\n  where.OR = [\n    { name: { contains: search, mode: 'insensitive' } },\n    { email: { contains: search, mode: 'insensitive' } },\n    { districtName: { contains: search, mode: 'insensitive' } },\n    { electedOffice: { contains: search, mode: 'insensitive' } },\n  ];\n}\n
"},{"location":"v2/backend/modules/representatives/#get-apirepresentativesid","title":"GET /api/representatives/:id","text":"

Get single cached representative by ID.

Authentication: Required (Admin roles)

Path Parameters:

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/representatives/clx1234567890\"\n

Response (200 OK):

Returns single representative object (same format as list).

Error Responses:

"},{"location":"v2/backend/modules/representatives/#delete-apirepresentativesby-postalpostalcode","title":"DELETE /api/representatives/by-postal/:postalCode","text":"

Clear all cached representatives for a specific postal code.

Authentication: Required (Admin roles)

Path Parameters:

Example Request:

curl -X DELETE -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/representatives/by-postal/M5H2N2\"\n

Response (200 OK):

{\n  \"deleted\": 3,\n  \"postalCode\": \"M5H2N2\"\n}\n

Use Cases:

"},{"location":"v2/backend/modules/representatives/#delete-apirepresentativesid","title":"DELETE /api/representatives/:id","text":"

Delete single cached representative by ID.

Authentication: Required (Admin roles)

Path Parameters:

Example Request:

curl -X DELETE -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/representatives/clx1234567890\"\n

Response (204 No Content):

No response body.

Error Responses:

"},{"location":"v2/backend/modules/representatives/#represent-api-integration","title":"Represent API Integration","text":""},{"location":"v2/backend/modules/representatives/#api-client","title":"API Client","text":"

The represent-api.client.ts file provides a typed HTTP client for the Represent API.

Base URL:

const REPRESENT_API_URL = 'https://represent.opennorth.ca';\n

Configuration:

Set REPRESENT_API_URL in .env to override (default: https://represent.opennorth.ca).

Methods:

class RepresentApiClient {\n  // Lookup by postal code\n  async getByPostalCode(code: string): Promise<RepresentPostalCodeResponse>;\n\n  // Health check\n  async testConnection(): Promise<{ ok: boolean; message: string }>;\n}\n
"},{"location":"v2/backend/modules/representatives/#rate-limiting","title":"Rate Limiting","text":"

Limits:

Implementation:

In-memory sliding window rate limiter:

const RATE_LIMIT = 55;\nconst RATE_WINDOW_MS = 60_000;\nconst requestTimestamps: number[] = [];\n\nfunction checkRateLimit(): boolean {\n  const now = Date.now();\n  // Remove timestamps outside the window\n  while (requestTimestamps.length > 0 && requestTimestamps[0] < now - RATE_WINDOW_MS) {\n    requestTimestamps.shift();\n  }\n  return requestTimestamps.length < RATE_LIMIT;\n}\n\nfunction recordRequest(): void {\n  requestTimestamps.push(Date.now());\n}\n

Behavior:

"},{"location":"v2/backend/modules/representatives/#response-schema","title":"Response Schema","text":"
interface RepresentPostalCodeResponse {\n  city: string | null;\n  province: string | null;\n  centroid: { type: string; coordinates: [number, number] } | null;\n  representatives_centroid: RepresentRepresentative[];\n  representatives_concordance: RepresentRepresentative[];\n}\n\ninterface RepresentRepresentative {\n  name: string;\n  email: string | null;\n  elected_office: string;\n  district_name: string;\n  party_name: string | null;\n  representative_set_name: string;\n  url: string;\n  photo_url: string | null;\n  offices: RepresentOffice[];\n}\n\ninterface RepresentOffice {\n  type?: string;       // \"constituency\" or \"legislature\"\n  tel?: string;        // Phone number\n  fax?: string;        // Fax number\n  postal?: string;     // Mailing address\n}\n

Centroid vs. Concordance:

"},{"location":"v2/backend/modules/representatives/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/representatives/#representativesservicelookupbypostalcodecode-forcerefresh","title":"representativesService.lookupByPostalCode(code, forceRefresh)","text":"

Cache-first representative lookup.

Parameters:

Returns:

{\n  source: 'cache' | 'api';\n  postalCode: string;\n  location: { city: string | null; province: string | null };\n  representatives: Representative[];\n}\n

Logic Flow:

  1. Check cache unless forceRefresh=true
  2. If cached data found, return immediately with source: 'cache'
  3. If no cache or forceRefresh, call Represent API
  4. Merge centroid + concordance representatives and deduplicate
  5. Fire-and-forget cache write (delete old, insert new, upsert postal code)
  6. Return API results with source: 'api' (don't wait for cache)

Fire-and-Forget Caching:

const cacheWrite = async () => {\n  try {\n    // Delete old cached reps for this postal code\n    await prisma.representative.deleteMany({ where: { postalCode: code } });\n\n    // Cache new reps\n    await prisma.representative.createMany({\n      data: uniqueReps.map((rep) => ({\n        postalCode: code,\n        name: rep.name || null,\n        email: rep.email || null,\n        districtName: rep.district_name || null,\n        electedOffice: rep.elected_office || null,\n        partyName: rep.party_name || null,\n        representativeSetName: rep.representative_set_name || null,\n        url: rep.url || null,\n        photoUrl: rep.photo_url || null,\n        offices: rep.offices ? (rep.offices as unknown as Prisma.InputJsonValue) : Prisma.JsonNull,\n      })),\n    });\n\n    // Upsert postal code cache (city, province, centroid)\n    await postalCodesService.upsert({\n      postalCode: code,\n      city: apiResponse.city,\n      province: apiResponse.province,\n      centroidLat: coords ? coords[1] : null,\n      centroidLng: coords ? coords[0] : null,\n    });\n  } catch (err) {\n    logger.error('Failed to cache representatives', { postalCode: code, error: err });\n  }\n};\n\n// Don't await \u2014 fire and forget\ncacheWrite();\n

Why Fire-and-Forget?

"},{"location":"v2/backend/modules/representatives/#representativesservicefindallfilters","title":"representativesService.findAll(filters)","text":"

List cached representatives with pagination and search.

Parameters:

{\n  page: number;      // Page number (default: 1)\n  limit: number;     // Results per page (max 100, default: 20)\n  search?: string;   // Search term (optional)\n  postalCode?: string; // Filter by postal code (optional)\n}\n

Returns:

{\n  representatives: Representative[];\n  pagination: {\n    page: number;\n    limit: number;\n    total: number;\n    totalPages: number;\n  };\n}\n
"},{"location":"v2/backend/modules/representatives/#representativesservicefindbyidid","title":"representativesService.findById(id)","text":"

Get single cached representative by ID.

Throws: AppError(404) if not found

"},{"location":"v2/backend/modules/representatives/#representativesserviceclearbypostalcodecode","title":"representativesService.clearByPostalCode(code)","text":"

Delete all cached representatives for a postal code.

Returns:

{\n  deleted: number;      // Count of deleted records\n  postalCode: string;\n}\n
"},{"location":"v2/backend/modules/representatives/#representativesservicedeletebyidid","title":"representativesService.deleteById(id)","text":"

Delete single cached representative by ID.

Throws: AppError(404) if not found

"},{"location":"v2/backend/modules/representatives/#representativesservicetestapiconnection","title":"representativesService.testApiConnection()","text":"

Test connectivity to Represent API.

Returns:

{\n  ok: boolean;\n  message: string;\n}\n

Implementation:

Calls Represent API's /boundary-sets/?limit=1 endpoint (lightweight health check).

"},{"location":"v2/backend/modules/representatives/#representativesservicegetcachestats","title":"representativesService.getCacheStats()","text":"

Get cache statistics.

Returns:

{\n  totalRepresentatives: number;         // Total cached representative records\n  postalCodesWithRepresentatives: number; // Unique postal codes with reps\n  totalPostalCodes: number;              // Total postal codes in cache\n}\n

Implementation:

const [totalReps, postalCodesWithReps, totalPostalCodes] = await Promise.all([\n  prisma.representative.count(),\n  prisma.representative.groupBy({ by: ['postalCode'] }).then((g) => g.length),\n  prisma.postalCodeCache.count(),\n]);\n\nreturn {\n  totalRepresentatives: totalReps,\n  postalCodesWithRepresentatives: postalCodesWithReps,\n  totalPostalCodes,\n};\n
"},{"location":"v2/backend/modules/representatives/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/representatives/#list-representatives-schema","title":"List Representatives Schema","text":"
export const listRepresentativesSchema = z.object({\n  page: z.coerce.number().int().positive().default(1),\n  limit: z.coerce.number().int().positive().max(100).default(20),\n  search: z.string().optional(),\n  postalCode: z.string().optional(),\n});\n\nexport type ListRepresentativesInput = z.infer<typeof listRepresentativesSchema>;\n

Coercion:

"},{"location":"v2/backend/modules/representatives/#integration-with-postal-codes-module","title":"Integration with Postal Codes Module","text":"

The representatives module integrates with the postal codes module (api/src/modules/influence/postal-codes/) for location metadata.

PostalCodeCache Model:

model PostalCodeCache {\n  id          String    @id @default(cuid())\n  postalCode  String    @unique\n  city        String?\n  province    String?\n  centroidLat Float?\n  centroidLng Float?\n  cachedAt    DateTime  @default(now())\n}\n

Integration Points:

  1. Lookup: When returning cached representatives, fetch city/province from PostalCodeCache:
const postalInfo = await postalCodesService.findByPostalCode(code);\nreturn {\n  source: 'cache',\n  location: {\n    city: postalInfo?.city ?? null,\n    province: postalInfo?.province ?? null,\n  },\n  representatives: cached,\n};\n
  1. Cache Write: After calling Represent API, upsert postal code with location data:
await postalCodesService.upsert({\n  postalCode: code,\n  city: apiResponse.city,\n  province: apiResponse.province,\n  centroidLat: coords ? coords[1] : null,\n  centroidLng: coords ? coords[0] : null,\n});\n
"},{"location":"v2/backend/modules/representatives/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/representatives/#public-lookup-representatives-by-postal-code","title":"Public: Lookup Representatives by Postal Code","text":"
import axios from 'axios';\n\nconst lookupRepresentatives = async (postalCode: string) => {\n  const { data } = await axios.get(\n    `/api/representatives/by-postal/${postalCode}`\n  );\n\n  console.log(`Source: ${data.source}`); // \"cache\" or \"api\"\n  console.log(`Location: ${data.location.city}, ${data.location.province}`);\n\n  data.representatives.forEach((rep) => {\n    console.log(`${rep.name} (${rep.electedOffice}) - ${rep.email}`);\n  });\n\n  return data;\n};\n\n// Cache-first lookup\nawait lookupRepresentatives('M5H2N2');\n\n// Force refresh from API\nconst { data } = await axios.get('/api/representatives/by-postal/M5H2N2?refresh=true');\n
"},{"location":"v2/backend/modules/representatives/#admin-get-cache-statistics","title":"Admin: Get Cache Statistics","text":"
import { api } from '@/lib/api';\n\nconst getCacheStats = async () => {\n  const { data } = await api.get('/api/representatives/cache-stats');\n\n  console.log(`Total Representatives: ${data.totalRepresentatives}`);\n  console.log(`Postal Codes with Reps: ${data.postalCodesWithRepresentatives}`);\n  console.log(`Total Postal Codes: ${data.totalPostalCodes}`);\n\n  return data;\n};\n
"},{"location":"v2/backend/modules/representatives/#admin-clear-cache-for-postal-code","title":"Admin: Clear Cache for Postal Code","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst clearPostalCodeCache = async (postalCode: string) => {\n  try {\n    const { data } = await api.delete(`/api/representatives/by-postal/${postalCode}`);\n    message.success(`Cleared ${data.deleted} representatives for ${postalCode}`);\n  } catch (error) {\n    message.error('Failed to clear cache');\n  }\n};\n
"},{"location":"v2/backend/modules/representatives/#admin-search-cached-representatives","title":"Admin: Search Cached Representatives","text":"
import { api } from '@/lib/api';\n\nconst searchRepresentatives = async (search: string, page: number = 1) => {\n  const { data } = await api.get('/api/representatives', {\n    params: { search, page, limit: 20 },\n  });\n\n  return {\n    representatives: data.representatives,\n    pagination: data.pagination,\n  };\n};\n
"},{"location":"v2/backend/modules/representatives/#frontend-integration","title":"Frontend Integration","text":"

The RepresentativesPage component (admin/src/pages/RepresentativesPage.tsx) provides:

State Management:

const [representatives, setRepresentatives] = useState<Representative[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });\nconst [filters, setFilters] = useState({ search: '', postalCode: '' });\nconst [stats, setStats] = useState({ totalRepresentatives: 0, postalCodesWithRepresentatives: 0, totalPostalCodes: 0 });\n
"},{"location":"v2/backend/modules/representatives/#performance-considerations","title":"Performance Considerations","text":"

Cache-First Strategy:

Rate Limiting:

Database Indexing:

Deduplication:

"},{"location":"v2/backend/modules/representatives/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/representatives/#issue-represent-api-rate-limit-reached","title":"Issue: \"Represent API rate limit reached\"","text":"

Cause: More than 55 requests in 60-second window

Solution:

"},{"location":"v2/backend/modules/representatives/#issue-cached-data-is-stale","title":"Issue: Cached data is stale","text":"

Cause: Representative changed after election

Solution:

"},{"location":"v2/backend/modules/representatives/#issue-postal-code-returns-no-representatives","title":"Issue: Postal code returns no representatives","text":"

Cause: Invalid postal code or Represent API doesn't have data

Solution:

"},{"location":"v2/backend/modules/representatives/#issue-duplicate-representatives-in-cache","title":"Issue: Duplicate representatives in cache","text":"

Cause: Deduplication bug or manual database insertion

Solution:

"},{"location":"v2/backend/modules/representatives/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/backend/modules/responses/","title":"Responses Module","text":""},{"location":"v2/backend/modules/responses/#overview","title":"Overview","text":"

The Responses module manages the public response wall for advocacy campaigns, allowing users to share representative responses (emails, letters, phone calls, etc.) with email verification, upvoting, and admin moderation. It features a dual verification system (verify or report links), IP-based and user-based upvoting, and comprehensive moderation tools.

Key Features:

"},{"location":"v2/backend/modules/responses/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/influence/responses/responses.routes.ts 3 routers (campaign public, responses public, admin) with 12 endpoints api/src/modules/influence/responses/responses.service.ts Response business logic + email verification api/src/modules/influence/responses/responses.schemas.ts Zod validation schemas"},{"location":"v2/backend/modules/responses/#database-models","title":"Database Models","text":""},{"location":"v2/backend/modules/responses/#representativeresponse","title":"RepresentativeResponse","text":"
model RepresentativeResponse {\n  id                   String           @id @default(cuid())\n  campaignId           String\n  campaign             Campaign         @relation(fields: [campaignId], references: [id], onDelete: Cascade)\n  campaignSlug         String\n\n  representativeName   String\n  representativeTitle  String?\n  representativeLevel  GovernmentLevel\n  representativeEmail  String?\n\n  responseType         ResponseType\n  responseText         String           @db.Text\n  userComment          String?          @db.Text\n  screenshotUrl        String?\n\n  // Submitter info\n  submittedByUserId    String?\n  submittedByUser      User?            @relation(\"ResponseSubmitter\", fields: [submittedByUserId], references: [id], onDelete: SetNull)\n  submittedByName      String?\n  submittedByEmail     String?\n  isAnonymous          Boolean          @default(false)\n  submittedIp          String?\n\n  // Moderation\n  status               ResponseStatus   @default(PENDING)\n\n  // Verification\n  isVerified           Boolean          @default(false)\n  verificationToken    String?\n  verificationSentAt   DateTime?\n  verifiedAt           DateTime?\n  verifiedBy           String?\n\n  // Upvoting\n  upvoteCount          Int              @default(0)\n  upvotes              ResponseUpvote[]\n\n  createdAt            DateTime         @default(now())\n  updatedAt            DateTime         @updatedAt\n\n  @@index([campaignId])\n  @@index([campaignSlug])\n  @@index([status])\n  @@map(\"representative_responses\")\n}\n\nenum ResponseType {\n  EMAIL\n  LETTER\n  PHONE_CALL\n  MEETING\n  SOCIAL_MEDIA\n  OTHER\n}\n\nenum ResponseStatus {\n  PENDING   // Awaiting moderation\n  APPROVED  // Visible on public wall\n  REJECTED  // Removed/disputed\n}\n
"},{"location":"v2/backend/modules/responses/#responseupvote","title":"ResponseUpvote","text":"
model ResponseUpvote {\n  id         String  @id @default(cuid())\n  responseId String\n  response   RepresentativeResponse @relation(fields: [responseId], references: [id], onDelete: Cascade)\n  userId     String?\n  user       User?   @relation(fields: [userId], references: [id], onDelete: SetNull)\n  userEmail  String?\n  upvotedIp  String?\n\n  @@unique([responseId, userId])      // Logged-in users: one upvote per response\n  @@unique([responseId, upvotedIp])   // Anonymous users: one upvote per IP per response\n  @@map(\"response_upvotes\")\n}\n

Upvoting Logic:

"},{"location":"v2/backend/modules/responses/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/responses/#campaign-scoped-public-endpoints-no-authentication","title":"Campaign-Scoped Public Endpoints (No Authentication)","text":"Method Path Description GET /api/campaigns/:slug/responses List approved responses for campaign GET /api/campaigns/:slug/response-stats Get response statistics for campaign POST /api/campaigns/:slug/responses Submit new response (rate-limited)"},{"location":"v2/backend/modules/responses/#response-scoped-public-endpoints-optional-authentication","title":"Response-Scoped Public Endpoints (Optional Authentication)","text":"Method Path Description POST /api/responses/:id/upvote Upvote a response DELETE /api/responses/:id/upvote Remove upvote from response GET /api/responses/:id/verify/:token Verify response (returns HTML page) GET /api/responses/:id/report/:token Report response as invalid (returns HTML page)"},{"location":"v2/backend/modules/responses/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":"Method Path Auth Description GET /api/responses Admin roles List all responses (paginated, filtered) PATCH /api/responses/:id/status Admin roles Update response status POST /api/responses/:id/resend-verification Admin roles Resend verification email DELETE /api/responses/:id Admin roles Delete response

Admin Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

"},{"location":"v2/backend/modules/responses/#public-endpoint-details","title":"Public Endpoint Details","text":""},{"location":"v2/backend/modules/responses/#post-apicampaignsslugresponses","title":"POST /api/campaigns/:slug/responses","text":"

Submit a new representative response to a campaign.

Rate Limiting: 10 requests per minute per IP

Path Parameters:

Request Body:

{\n  \"representativeName\": \"Chrystia Freeland\",\n  \"representativeTitle\": \"Deputy Prime Minister\",\n  \"representativeLevel\": \"FEDERAL\",\n  \"representativeEmail\": \"chrystia.freeland@parl.gc.ca\",\n  \"responseType\": \"EMAIL\",\n  \"responseText\": \"Thank you for writing. I appreciate your concerns regarding climate change and am committed to...\",\n  \"userComment\": \"Received this response 2 days after sending my email!\",\n  \"submittedByName\": \"Jane Doe\",\n  \"submittedByEmail\": \"jane@example.com\",\n  \"isAnonymous\": false,\n  \"sendVerification\": true\n}\n

Field Descriptions:

Response (201 Created):

{\n  \"id\": \"clx1234567890\",\n  \"status\": \"PENDING\",\n  \"verificationSent\": true\n}\n

Verification Email Flow:

If sendVerification=true and representativeEmail is provided, an email is sent to the representative with:

Example Verification Email:

Subject: Please verify this response submission for \"Climate Action Now\" campaign\n\nDear Representative,\n\nA constituent has submitted a response from you for the \"Climate Action Now\" campaign on Changemaker Lite.\n\nResponse Type: Email\nResponse Text: \"Thank you for writing. I appreciate your concerns regarding...\"\nSubmitted By: Jane Doe\n\nIf this is a genuine response from you, please verify it:\nhttps://api.cmlite.org/api/responses/clx1234567890/verify/abc123...\n\nIf you did not send this response, or it is inaccurate, please report it:\nhttps://api.cmlite.org/api/responses/clx1234567890/report/abc123...\n\nThis link expires in 30 days.\n

Error Responses:

Campaign Requirements:

"},{"location":"v2/backend/modules/responses/#get-apicampaignsslugresponses","title":"GET /api/campaigns/:slug/responses","text":"

List approved responses for a campaign.

Path Parameters:

Query Parameters:

Parameter Type Required Default Description page number No 1 Page number limit number No 20 Results per page (max 100) sort string No recent Sort order: recent, upvotes, verified level GovernmentLevel No - Filter by government level

Example Request:

# Recent responses\ncurl \"http://api.cmlite.org/api/campaigns/climate-action-now/responses?page=1&limit=10\"\n\n# Sort by upvotes\ncurl \"http://api.cmlite.org/api/campaigns/climate-action-now/responses?sort=upvotes\"\n\n# Filter by federal only\ncurl \"http://api.cmlite.org/api/campaigns/climate-action-now/responses?level=FEDERAL\"\n

Response (200 OK):

{\n  \"responses\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"representativeName\": \"Chrystia Freeland\",\n      \"representativeTitle\": \"Deputy Prime Minister\",\n      \"representativeLevel\": \"FEDERAL\",\n      \"responseType\": \"EMAIL\",\n      \"responseText\": \"Thank you for writing. I appreciate your concerns...\",\n      \"userComment\": \"Received this response 2 days after sending!\",\n      \"submittedByName\": \"Jane Doe\",\n      \"isAnonymous\": false,\n      \"isVerified\": true,\n      \"verifiedAt\": \"2026-02-10T12:00:00.000Z\",\n      \"upvoteCount\": 42,\n      \"createdAt\": \"2026-02-08T12:00:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 10,\n    \"total\": 89,\n    \"totalPages\": 9\n  }\n}\n

Response Fields:

Sorting:

switch (sort) {\n  case 'upvotes':\n    orderBy = { upvoteCount: 'desc' };\n    break;\n  case 'verified':\n    orderBy = { isVerified: 'desc' };\n    break;\n  default: // 'recent'\n    orderBy = { createdAt: 'desc' };\n}\n
"},{"location":"v2/backend/modules/responses/#get-apicampaignsslugresponse-stats","title":"GET /api/campaigns/:slug/response-stats","text":"

Get aggregate statistics for campaign responses.

Path Parameters:

Example Request:

curl \"http://api.cmlite.org/api/campaigns/climate-action-now/response-stats\"\n

Response (200 OK):

{\n  \"total\": 89,\n  \"verified\": 42,\n  \"totalUpvotes\": 347,\n  \"byLevel\": {\n    \"FEDERAL\": 32,\n    \"PROVINCIAL\": 28,\n    \"MUNICIPAL\": 21,\n    \"SCHOOL_BOARD\": 8\n  }\n}\n

Field Descriptions:

"},{"location":"v2/backend/modules/responses/#post-apiresponsesidupvote","title":"POST /api/responses/:id/upvote","text":"

Upvote a response.

Authentication: Optional (supports both logged-in and anonymous users)

Path Parameters:

Example Request:

# Anonymous upvote (tracked by IP)\ncurl -X POST \"http://api.cmlite.org/api/responses/clx1234567890/upvote\"\n\n# Logged-in upvote (tracked by user ID)\ncurl -X POST -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/responses/clx1234567890/upvote\"\n

Response (200 OK):

{\n  \"success\": true\n}\n

Response (200 OK, Already Upvoted):

{\n  \"success\": false,\n  \"alreadyUpvoted\": true\n}\n

Upvoting Logic:

  1. Verify response exists and is APPROVED
  2. Create ResponseUpvote record:
  3. Logged-in: userId + responseId (allows upvoting from multiple IPs)
  4. Anonymous: upvotedIp + responseId (prevents duplicate upvotes from same IP)
  5. Increment upvoteCount on response
  6. If duplicate (Prisma P2002 error), return alreadyUpvoted: true

Error Responses:

"},{"location":"v2/backend/modules/responses/#delete-apiresponsesidupvote","title":"DELETE /api/responses/:id/upvote","text":"

Remove upvote from a response.

Authentication: Optional (supports both logged-in and anonymous users)

Path Parameters:

Example Request:

# Remove anonymous upvote\ncurl -X DELETE \"http://api.cmlite.org/api/responses/clx1234567890/upvote\"\n\n# Remove logged-in upvote\ncurl -X DELETE -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/responses/clx1234567890/upvote\"\n

Response (200 OK):

{\n  \"success\": true\n}\n

Response (200 OK, Not Upvoted):

{\n  \"success\": false\n}\n

Logic:

  1. Delete ResponseUpvote record matching responseId + userId (or upvotedIp if anonymous)
  2. Decrement upvoteCount if deleted
  3. Return success: false if no upvote record found
"},{"location":"v2/backend/modules/responses/#get-apiresponsesidverifytoken","title":"GET /api/responses/:id/verify/:token","text":"

Verify a response (representative confirms authenticity). Returns HTML result page.

Path Parameters:

Example URL:

https://api.cmlite.org/api/responses/clx1234567890/verify/abc123...\n

Response (200 OK, Success):

Returns HTML page with success message:

<!DOCTYPE html>\n<html>\n<head>\n  <title>Response Verified - Changemaker Lite</title>\n  ...\n</head>\n<body>\n  <div class=\"container\">\n    <div class=\"card\">\n      <div class=\"icon\">\u2713</div>\n      <h1 style=\"color: #16a34a\">Response Verified</h1>\n      <p>Thank you for verifying this response for the \"Climate Action Now\" campaign. The response has been approved and will now appear on the public response wall.</p>\n    </div>\n    <div class=\"brand\">Powered by <strong>Changemaker Lite</strong></div>\n  </div>\n</body>\n</html>\n

Response (200 OK, Failed):

Returns HTML page with error message:

Database Changes on Success:

await prisma.representativeResponse.update({\n  where: { id: responseId },\n  data: {\n    isVerified: true,\n    verifiedAt: new Date(),\n    verifiedBy: response.representativeEmail || 'Representative',\n    status: ResponseStatus.APPROVED,\n  },\n});\n

Expiry Logic:

const VERIFICATION_EXPIRY_DAYS = 30;\n\nif (response.verificationSentAt) {\n  const daysSinceSent = (Date.now() - response.verificationSentAt.getTime()) / (1000 * 60 * 60 * 24);\n  if (daysSinceSent > VERIFICATION_EXPIRY_DAYS) {\n    return { success: false, reason: 'Verification link has expired' };\n  }\n}\n
"},{"location":"v2/backend/modules/responses/#get-apiresponsesidreporttoken","title":"GET /api/responses/:id/report/:token","text":"

Report a response as invalid (representative disputes it). Returns HTML result page.

Path Parameters:

Example URL:

https://api.cmlite.org/api/responses/clx1234567890/report/abc123...\n

Response (200 OK, Success):

Returns HTML page with confirmation:

<h1 style=\"color: #dc2626\">Response Reported</h1>\n<p>This response for the \"Climate Action Now\" campaign has been flagged as invalid and removed from the public response wall. Thank you for letting us know.</p>\n

Database Changes on Success:

await prisma.representativeResponse.update({\n  where: { id: responseId },\n  data: {\n    status: ResponseStatus.REJECTED,\n    isVerified: false,\n    verifiedBy: `Disputed by ${response.representativeEmail || 'representative'}`,\n  },\n});\n

Use Cases:

"},{"location":"v2/backend/modules/responses/#admin-endpoint-details","title":"Admin Endpoint Details","text":""},{"location":"v2/backend/modules/responses/#get-apiresponses","title":"GET /api/responses","text":"

List all responses with admin filters.

Authentication: Required (Admin roles)

Query Parameters:

Parameter Type Required Default Description page number No 1 Page number limit number No 20 Results per page (max 100) status ResponseStatus No - Filter by status (PENDING, APPROVED, REJECTED) campaignId string No - Filter by campaign ID search string No - Search name, response text, or submitter

Example Request:

# Pending responses\ncurl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/responses?status=PENDING&page=1&limit=10\"\n\n# Search\ncurl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/responses?search=climate\"\n\n# Campaign-specific\ncurl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/responses?campaignId=clxCampaign123\"\n

Response (200 OK):

{\n  \"responses\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"representativeName\": \"Chrystia Freeland\",\n      \"representativeTitle\": \"Deputy Prime Minister\",\n      \"representativeLevel\": \"FEDERAL\",\n      \"representativeEmail\": \"chrystia.freeland@parl.gc.ca\",\n      \"responseType\": \"EMAIL\",\n      \"responseText\": \"Thank you for writing...\",\n      \"userComment\": \"Received this response 2 days after sending!\",\n      \"submittedByName\": \"Jane Doe\",\n      \"submittedByEmail\": \"jane@example.com\",\n      \"isAnonymous\": false,\n      \"status\": \"PENDING\",\n      \"isVerified\": false,\n      \"verifiedAt\": null,\n      \"verifiedBy\": null,\n      \"upvoteCount\": 0,\n      \"createdAt\": \"2026-02-08T12:00:00.000Z\",\n      \"campaign\": {\n        \"id\": \"clxCampaign123\",\n        \"title\": \"Climate Action Now\",\n        \"slug\": \"climate-action-now\"\n      }\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 10,\n    \"total\": 23,\n    \"totalPages\": 3\n  }\n}\n

Differences from Public Route:

Search Logic:

if (search) {\n  where.OR = [\n    { representativeName: { contains: search, mode: 'insensitive' } },\n    { responseText: { contains: search, mode: 'insensitive' } },\n    { submittedByName: { contains: search, mode: 'insensitive' } },\n  ];\n}\n
"},{"location":"v2/backend/modules/responses/#patch-apiresponsesidstatus","title":"PATCH /api/responses/:id/status","text":"

Update response status (approve or reject).

Authentication: Required (Admin roles)

Path Parameters:

Request Body:

{\n  \"status\": \"APPROVED\"\n}\n

Valid Statuses: PENDING, APPROVED, REJECTED

Example Request:

# Approve response\ncurl -X PATCH -H \"Authorization: Bearer <token>\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"status\":\"APPROVED\"}' \\\n  \"http://api.cmlite.org/api/responses/clx1234567890/status\"\n\n# Reject response\ncurl -X PATCH -H \"Authorization: Bearer <token>\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"status\":\"REJECTED\"}' \\\n  \"http://api.cmlite.org/api/responses/clx1234567890/status\"\n

Response (200 OK):

Returns updated response object (same format as GET).

Error Responses:

Use Cases:

"},{"location":"v2/backend/modules/responses/#post-apiresponsesidresend-verification","title":"POST /api/responses/:id/resend-verification","text":"

Resend verification email to representative.

Authentication: Required (Admin roles)

Path Parameters:

Example Request:

curl -X POST -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/responses/clx1234567890/resend-verification\"\n

Response (200 OK):

{\n  \"success\": true\n}\n

Error Responses:

Logic:

  1. Retrieve existing response
  2. Regenerate verification token (or reuse existing)
  3. Update verificationToken and verificationSentAt in database
  4. Send verification email to representativeEmail

Use Cases:

"},{"location":"v2/backend/modules/responses/#delete-apiresponsesid","title":"DELETE /api/responses/:id","text":"

Delete a response permanently.

Authentication: Required (Admin roles)

Path Parameters:

Example Request:

curl -X DELETE -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/responses/clx1234567890\"\n

Response (204 No Content):

No response body.

Error Responses:

Cascading Deletes:

"},{"location":"v2/backend/modules/responses/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/responses/#responsesservicesubmitresponseslug-data-senderip","title":"responsesService.submitResponse(slug, data, senderIp)","text":"

Submit new response to campaign.

Parameters:

Returns:

{\n  id: string;\n  status: ResponseStatus;\n  verificationSent: boolean;\n}\n

Validation:

Verification Token:

let verificationToken: string | null = null;\n\nif (data.sendVerification && data.representativeEmail) {\n  verificationToken = randomBytes(32).toString('hex'); // 64-char hex string\n}\n

Metrics:

Calls recordResponseSubmission() to increment Prometheus counter.

"},{"location":"v2/backend/modules/responses/#responsesservicelistapprovedslug-filters","title":"responsesService.listApproved(slug, filters)","text":"

List approved responses for campaign with sorting.

Parameters:

{\n  page: number;\n  limit: number;\n  sort: 'recent' | 'upvotes' | 'verified';\n  level?: GovernmentLevel;\n}\n

Returns:

{\n  responses: Response[];\n  pagination: Pagination;\n}\n
"},{"location":"v2/backend/modules/responses/#responsesservicegetstatsslug","title":"responsesService.getStats(slug)","text":"

Get aggregate statistics for campaign responses.

Returns:

{\n  total: number;\n  verified: number;\n  totalUpvotes: number;\n  byLevel: Record<string, number>;\n}\n
"},{"location":"v2/backend/modules/responses/#responsesserviceupvoteresponseid-userip-userid","title":"responsesService.upvote(responseId, userIp, userId)","text":"

Upvote a response.

Parameters:

Returns:

{\n  success: boolean;\n  alreadyUpvoted?: boolean; // True if duplicate upvote attempt\n}\n

Logic:

try {\n  await prisma.responseUpvote.create({\n    data: {\n      responseId,\n      userId: userId || null,\n      upvotedIp: !userId ? (userIp || null) : null,\n    },\n  });\n\n  await prisma.representativeResponse.update({\n    where: { id: responseId },\n    data: { upvoteCount: { increment: 1 } },\n  });\n\n  return { success: true };\n} catch (err: any) {\n  if (err.code === 'P2002') {  // Prisma unique constraint violation\n    return { success: false, alreadyUpvoted: true };\n  }\n  throw err;\n}\n
"},{"location":"v2/backend/modules/responses/#responsesserviceremoveupvoteresponseid-userip-userid","title":"responsesService.removeUpvote(responseId, userIp, userId)","text":"

Remove upvote from response.

Parameters:

Returns:

{\n  success: boolean; // True if upvote was found and removed\n}\n
"},{"location":"v2/backend/modules/responses/#responsesserviceverifyresponseid-token","title":"responsesService.verify(responseId, token)","text":"

Verify a response via email link.

Parameters:

Returns:

{\n  success: boolean;\n  campaignTitle?: string; // On success\n  reason?: string;        // On failure\n}\n

Failure Reasons:

"},{"location":"v2/backend/modules/responses/#responsesservicereportresponseid-token","title":"responsesService.report(responseId, token)","text":"

Report a response as invalid via email link.

Parameters:

Returns:

{\n  success: boolean;\n  campaignTitle?: string;\n  reason?: string;\n}\n

Database Changes:

"},{"location":"v2/backend/modules/responses/#responsesservicefindallfilters-admin","title":"responsesService.findAll(filters) (Admin)","text":"

List all responses with admin filters.

Parameters:

{\n  page: number;\n  limit: number;\n  status?: ResponseStatus;\n  campaignId?: string;\n  search?: string;\n}\n

Returns:

{\n  responses: Response[];\n  pagination: Pagination;\n}\n
"},{"location":"v2/backend/modules/responses/#responsesserviceupdatestatusid-data-admin","title":"responsesService.updateStatus(id, data) (Admin)","text":"

Update response status.

Throws: AppError(404) if not found

"},{"location":"v2/backend/modules/responses/#responsesservicedeleteresponseid-admin","title":"responsesService.deleteResponse(id) (Admin)","text":"

Delete response permanently.

Throws: AppError(404) if not found

"},{"location":"v2/backend/modules/responses/#responsesserviceresendverificationid-admin","title":"responsesService.resendVerification(id) (Admin)","text":"

Resend verification email to representative.

Throws:

"},{"location":"v2/backend/modules/responses/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/responses/#submit-response-schema","title":"Submit Response Schema","text":"
export const submitResponseSchema = z.object({\n  representativeName: z.string().min(1, 'Representative name is required'),\n  representativeLevel: z.nativeEnum(GovernmentLevel),\n  responseType: z.nativeEnum(ResponseType),\n  responseText: z.string().min(1, 'Response text is required'),\n  representativeTitle: z.string().optional(),\n  representativeEmail: z.string().email().optional(),\n  userComment: z.string().optional(),\n  submittedByName: z.string().optional(),\n  submittedByEmail: z.string().email().optional(),\n  isAnonymous: z.boolean().optional().default(false),\n  sendVerification: z.boolean().optional().default(false),\n});\n
"},{"location":"v2/backend/modules/responses/#list-public-responses-schema","title":"List Public Responses Schema","text":"
export const listPublicResponsesSchema = z.object({\n  page: z.coerce.number().int().positive().default(1),\n  limit: z.coerce.number().int().positive().max(100).default(20),\n  sort: z.enum(['recent', 'upvotes', 'verified']).optional().default('recent'),\n  level: z.nativeEnum(GovernmentLevel).optional(),\n});\n
"},{"location":"v2/backend/modules/responses/#list-admin-responses-schema","title":"List Admin Responses Schema","text":"
export const listAdminResponsesSchema = z.object({\n  page: z.coerce.number().int().positive().default(1),\n  limit: z.coerce.number().int().positive().max(100).default(20),\n  status: z.nativeEnum(ResponseStatus).optional(),\n  campaignId: z.string().optional(),\n  search: z.string().optional(),\n});\n
"},{"location":"v2/backend/modules/responses/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/responses/#public-submit-response-with-verification","title":"Public: Submit Response with Verification","text":"
import axios from 'axios';\n\nconst submitResponse = async (campaignSlug: string) => {\n  const { data } = await axios.post(\n    `/api/campaigns/${campaignSlug}/responses`,\n    {\n      representativeName: 'Chrystia Freeland',\n      representativeTitle: 'Deputy Prime Minister',\n      representativeLevel: 'FEDERAL',\n      representativeEmail: 'chrystia.freeland@parl.gc.ca',\n      responseType: 'EMAIL',\n      responseText: 'Thank you for writing. I appreciate your concerns regarding...',\n      userComment: 'Received this response 2 days after sending!',\n      submittedByName: 'Jane Doe',\n      submittedByEmail: 'jane@example.com',\n      isAnonymous: false,\n      sendVerification: true,\n    }\n  );\n\n  console.log(`Response submitted: ${data.id}`);\n  console.log(`Verification sent: ${data.verificationSent}`);\n\n  return data;\n};\n
"},{"location":"v2/backend/modules/responses/#public-upvote-response","title":"Public: Upvote Response","text":"
import axios from 'axios';\nimport { message } from 'antd';\n\nconst upvoteResponse = async (responseId: string) => {\n  try {\n    const { data } = await axios.post(`/api/responses/${responseId}/upvote`);\n\n    if (data.success) {\n      message.success('Upvoted!');\n    } else if (data.alreadyUpvoted) {\n      message.info('You already upvoted this response');\n    }\n  } catch (error) {\n    message.error('Failed to upvote');\n  }\n};\n
"},{"location":"v2/backend/modules/responses/#admin-approve-response","title":"Admin: Approve Response","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst approveResponse = async (responseId: string) => {\n  try {\n    await api.patch(`/api/responses/${responseId}/status`, {\n      status: 'APPROVED',\n    });\n\n    message.success('Response approved');\n  } catch (error) {\n    message.error('Failed to approve response');\n  }\n};\n
"},{"location":"v2/backend/modules/responses/#admin-resend-verification","title":"Admin: Resend Verification","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst resendVerification = async (responseId: string) => {\n  try {\n    await api.post(`/api/responses/${responseId}/resend-verification`);\n    message.success('Verification email resent');\n  } catch (error: any) {\n    if (error.response?.status === 400) {\n      message.error('No representative email on record');\n    } else {\n      message.error('Failed to resend verification');\n    }\n  }\n};\n
"},{"location":"v2/backend/modules/responses/#frontend-integration","title":"Frontend Integration","text":""},{"location":"v2/backend/modules/responses/#responsespage-admin","title":"ResponsesPage (Admin)","text":"

The ResponsesPage component (admin/src/pages/ResponsesPage.tsx) provides:

"},{"location":"v2/backend/modules/responses/#responsewallpage-public","title":"ResponseWallPage (Public)","text":"

The ResponseWallPage component (admin/src/pages/public/ResponseWallPage.tsx) provides:

"},{"location":"v2/backend/modules/responses/#performance-considerations","title":"Performance Considerations","text":"

Upvote Constraints:

Indexing:

Pagination:

"},{"location":"v2/backend/modules/responses/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/responses/#issue-verification-email-not-delivered","title":"Issue: Verification email not delivered","text":"

Cause: SMTP configuration issue or email blocked by spam filter

Solution:

"},{"location":"v2/backend/modules/responses/#issue-verification-link-expired","title":"Issue: Verification link expired","text":"

Cause: More than 30 days since verification email sent

Solution:

"},{"location":"v2/backend/modules/responses/#issue-cant-upvote-response","title":"Issue: Can't upvote response","text":"

Cause: Already upvoted, or response not approved

Solution:

"},{"location":"v2/backend/modules/responses/#issue-response-not-appearing-on-public-wall","title":"Issue: Response not appearing on public wall","text":"

Cause: Status is PENDING or REJECTED

Solution:

"},{"location":"v2/backend/modules/responses/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/backend/modules/settings/","title":"Settings Module","text":""},{"location":"v2/backend/modules/settings/#overview","title":"Overview","text":"

The Settings module provides site-wide configuration management with a singleton pattern. It handles organization branding, theme customization, SMTP configuration, and feature toggles. The module implements field-level encryption for sensitive data (SMTP passwords) and provides separate endpoints for public and admin access.

Key Features:

"},{"location":"v2/backend/modules/settings/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/settings/settings.routes.ts Express router with 5 endpoints api/src/modules/settings/settings.service.ts Settings business logic with encryption api/src/modules/settings/settings.schemas.ts Zod validation schema api/src/utils/crypto.ts AES-256-GCM encryption/decryption"},{"location":"v2/backend/modules/settings/#database-model","title":"Database Model","text":"
model SiteSettings {\n  id  String @id @default(cuid())\n\n  // Organization\n  organizationName         String   @default(\"Changemaker Lite\")\n  organizationShortName    String   @default(\"CM\")\n  organizationLogoUrl      String?\n  organizationFaviconUrl   String?\n\n  // Admin theme\n  adminColorPrimary        String   @default(\"#1890ff\")\n  adminColorBgBase         String   @default(\"#ffffff\")\n\n  // Public theme\n  publicColorPrimary       String   @default(\"#3498db\")\n  publicColorBgBase        String   @default(\"#0d1b2a\")\n  publicColorBgContainer   String   @default(\"#1b2838\")\n  publicHeaderGradient     String?\n\n  // Text\n  footerText               String   @default(\"\u00a9 2026 Changemaker Lite\")\n  loginSubtitle            String   @default(\"Political Infrastructure Platform\")\n\n  // Email branding\n  emailFromName            String   @default(\"Changemaker Lite\")\n\n  // SMTP configuration (encrypted at rest)\n  smtpHost                 String   @default(\"mailhog\")\n  smtpPort                 Int      @default(1025)\n  smtpUser                 String   @default(\"\")\n  smtpPass                 String   @default(\"\")  // Encrypted with ENCRYPTION_KEY\n  smtpFromAddress          String   @default(\"noreply@cmlite.org\")\n  smtpActiveProvider       String   @default(\"mailhog\")\n  emailTestMode            Boolean  @default(true)\n  testEmailRecipient       String?\n\n  // Feature toggles\n  enableInfluence          Boolean  @default(true)\n  enableMap                Boolean  @default(true)\n  enableNewsletter         Boolean  @default(false)\n  enableLandingPages       Boolean  @default(true)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n}\n

Singleton Pattern:

Encryption:

"},{"location":"v2/backend/modules/settings/#api-endpoints","title":"API Endpoints","text":"Method Path Auth Description GET /api/settings None Get public settings (strips SMTP credentials) GET /api/settings/admin SUPER_ADMIN Get full settings (includes SMTP credentials) PUT /api/settings SUPER_ADMIN Update settings POST /api/settings/email/test-connection SUPER_ADMIN Test SMTP connection POST /api/settings/email/test-send SUPER_ADMIN Send test email"},{"location":"v2/backend/modules/settings/#endpoint-details","title":"Endpoint Details","text":""},{"location":"v2/backend/modules/settings/#get-apisettings","title":"GET /api/settings","text":"

Get public-safe settings (no authentication required). Used by login page and public pages.

Security: Strips SMTP credentials before returning: - smtpHost - smtpPort - smtpUser - smtpPass - smtpFromAddress - testEmailRecipient

Example Request:

curl http://api.cmlite.org/api/settings\n

Response (200 OK):

{\n  \"id\": \"clx1234567890\",\n  \"organizationName\": \"Changemaker Lite\",\n  \"organizationShortName\": \"CM\",\n  \"organizationLogoUrl\": \"https://example.com/logo.png\",\n  \"organizationFaviconUrl\": \"https://example.com/favicon.ico\",\n  \"adminColorPrimary\": \"#1890ff\",\n  \"adminColorBgBase\": \"#ffffff\",\n  \"publicColorPrimary\": \"#3498db\",\n  \"publicColorBgBase\": \"#0d1b2a\",\n  \"publicColorBgContainer\": \"#1b2838\",\n  \"publicHeaderGradient\": \"linear-gradient(135deg, #667eea 0%, #764ba2 100%)\",\n  \"footerText\": \"\u00a9 2026 Changemaker Lite\",\n  \"loginSubtitle\": \"Political Infrastructure Platform\",\n  \"emailFromName\": \"Changemaker Lite\",\n  \"enableInfluence\": true,\n  \"enableMap\": true,\n  \"enableNewsletter\": false,\n  \"enableLandingPages\": true,\n  \"createdAt\": \"2026-02-01T12:00:00.000Z\",\n  \"updatedAt\": \"2026-02-11T12:00:00.000Z\"\n}\n

Implementation:

router.get(\n  '/',\n  async (_req: Request, res: Response, next: NextFunction) => {\n    try {\n      const settings = await siteSettingsService.getPublic();\n      res.json(settings);\n    } catch (err) {\n      next(err);\n    }\n  }\n);\n

Service Logic:

async getPublic(): Promise<Omit<SiteSettings, typeof SENSITIVE_FIELDS[number]>> {\n  const settings = await this.get();\n  const result = { ...settings } as Record<string, unknown>;\n  for (const field of SENSITIVE_FIELDS) {\n    delete result[field];\n  }\n  return result as Omit<SiteSettings, typeof SENSITIVE_FIELDS[number]>;\n}\n
"},{"location":"v2/backend/modules/settings/#get-apisettingsadmin","title":"GET /api/settings/admin","text":"

Get full settings including SMTP credentials (SUPER_ADMIN only).

Request Headers:

Authorization: Bearer <access_token>\n

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  http://api.cmlite.org/api/settings/admin\n

Response (200 OK):

{\n  \"id\": \"clx1234567890\",\n  \"organizationName\": \"Changemaker Lite\",\n  \"organizationShortName\": \"CM\",\n  \"organizationLogoUrl\": \"https://example.com/logo.png\",\n  \"organizationFaviconUrl\": \"https://example.com/favicon.ico\",\n  \"adminColorPrimary\": \"#1890ff\",\n  \"adminColorBgBase\": \"#ffffff\",\n  \"publicColorPrimary\": \"#3498db\",\n  \"publicColorBgBase\": \"#0d1b2a\",\n  \"publicColorBgContainer\": \"#1b2838\",\n  \"publicHeaderGradient\": \"linear-gradient(135deg, #667eea 0%, #764ba2 100%)\",\n  \"footerText\": \"\u00a9 2026 Changemaker Lite\",\n  \"loginSubtitle\": \"Political Infrastructure Platform\",\n  \"emailFromName\": \"Changemaker Lite\",\n  \"smtpHost\": \"smtp.sendgrid.net\",\n  \"smtpPort\": 587,\n  \"smtpUser\": \"apikey\",\n  \"smtpPass\": \"SG.xxxxxxxxxxxx\",\n  \"smtpFromAddress\": \"noreply@cmlite.org\",\n  \"smtpActiveProvider\": \"production\",\n  \"emailTestMode\": false,\n  \"testEmailRecipient\": \"admin@example.com\",\n  \"enableInfluence\": true,\n  \"enableMap\": true,\n  \"enableNewsletter\": false,\n  \"enableLandingPages\": true,\n  \"createdAt\": \"2026-02-01T12:00:00.000Z\",\n  \"updatedAt\": \"2026-02-11T12:00:00.000Z\"\n}\n

Error Responses:

Implementation:

router.get(\n  '/admin',\n  authenticate,\n  requireRole(UserRole.SUPER_ADMIN),\n  async (_req: Request, res: Response, next: NextFunction) => {\n    try {\n      const settings = await siteSettingsService.get();\n      res.json(settings);\n    } catch (err) {\n      next(err);\n    }\n  }\n);\n

Decryption:

/** Full settings with encrypted fields decrypted (admin use) */\nasync get() {\n  let settings = await prisma.siteSettings.findFirst();\n  if (!settings) {\n    settings = await prisma.siteSettings.create({ data: {} });\n  }\n  return decryptSettings(settings);\n}\n\nfunction decryptSettings(settings: SiteSettings): SiteSettings {\n  for (const field of ENCRYPTED_FIELDS) {\n    const value = settings[field];\n    if (typeof value === 'string' && value) {\n      (settings as Record<string, unknown>)[field] = decrypt(value);\n    }\n  }\n  return settings;\n}\n
"},{"location":"v2/backend/modules/settings/#put-apisettings","title":"PUT /api/settings","text":"

Update site settings (SUPER_ADMIN only). Automatically rebuilds email transporter if SMTP fields change.

Request Headers:

Authorization: Bearer <access_token>\nContent-Type: application/json\n

Request Body (Partial Update):

{\n  \"organizationName\": \"My Campaign\",\n  \"organizationShortName\": \"MC\",\n  \"organizationLogoUrl\": \"https://example.com/new-logo.png\",\n  \"publicColorPrimary\": \"#ff6b6b\",\n  \"smtpHost\": \"smtp.sendgrid.net\",\n  \"smtpPort\": 587,\n  \"smtpUser\": \"apikey\",\n  \"smtpPass\": \"SG.new_api_key\",\n  \"smtpFromAddress\": \"hello@mycampaign.org\",\n  \"smtpActiveProvider\": \"production\",\n  \"emailTestMode\": false,\n  \"enableNewsletter\": true\n}\n

All fields are optional (partial updates supported).

Response (200 OK):

Returns updated settings (same format as GET /api/settings/admin).

Error Responses:

Implementation:

router.put(\n  '/',\n  authenticate,\n  requireRole(UserRole.SUPER_ADMIN),\n  validate(updateSiteSettingsSchema),\n  async (req: Request, res: Response, next: NextFunction) => {\n    try {\n      const settings = await siteSettingsService.update(req.body);\n\n      // If SMTP-related fields were updated, rebuild the transporter\n      const smtpFields = ['smtpHost', 'smtpPort', 'smtpUser', 'smtpPass', 'smtpFromAddress', 'smtpActiveProvider', 'emailTestMode', 'testEmailRecipient'];\n      const hasSmtpChanges = smtpFields.some((f) => f in req.body);\n      if (hasSmtpChanges) {\n        await emailService.rebuildTransporter();\n      }\n\n      res.json(settings);\n    } catch (err) {\n      next(err);\n    }\n  }\n);\n

Encryption on Write:

async update(data: UpdateSiteSettingsInput) {\n  // Encrypt sensitive fields before writing to DB\n  const toWrite = { ...data };\n  for (const field of ENCRYPTED_FIELDS) {\n    if (field in toWrite && typeof toWrite[field] === 'string' && toWrite[field]) {\n      (toWrite as Record<string, unknown>)[field] = encrypt(toWrite[field] as string);\n    }\n  }\n\n  const existing = await prisma.siteSettings.findFirst();\n  let settings: SiteSettings;\n  if (existing) {\n    settings = await prisma.siteSettings.update({\n      where: { id: existing.id },\n      data: toWrite,\n    });\n  } else {\n    settings = await prisma.siteSettings.create({ data: toWrite });\n  }\n  return decryptSettings(settings);\n}\n

Email Transporter Rebuild:

When SMTP settings change, the email service transporter is automatically rebuilt:

const smtpFields = ['smtpHost', 'smtpPort', 'smtpUser', 'smtpPass', 'smtpFromAddress', 'smtpActiveProvider', 'emailTestMode', 'testEmailRecipient'];\nconst hasSmtpChanges = smtpFields.some((f) => f in req.body);\nif (hasSmtpChanges) {\n  await emailService.rebuildTransporter();\n}\n
"},{"location":"v2/backend/modules/settings/#post-apisettingsemailtest-connection","title":"POST /api/settings/email/test-connection","text":"

Test SMTP connection (SUPER_ADMIN only).

Request Headers:

Authorization: Bearer <access_token>\n

Example Request:

curl -X POST \\\n  -H \"Authorization: Bearer <token>\" \\\n  http://api.cmlite.org/api/settings/email/test-connection\n

Response (200 OK):

{\n  \"success\": true,\n  \"message\": \"SMTP connection verified\"\n}\n

Response (Failure):

{\n  \"success\": false,\n  \"message\": \"SMTP connection failed\"\n}\n

Implementation:

router.post(\n  '/email/test-connection',\n  authenticate,\n  requireRole(UserRole.SUPER_ADMIN),\n  async (_req: Request, res: Response, next: NextFunction) => {\n    try {\n      const success = await emailService.testConnection();\n      res.json({ success, message: success ? 'SMTP connection verified' : 'SMTP connection failed' });\n    } catch (err) {\n      next(err);\n    }\n  }\n);\n
"},{"location":"v2/backend/modules/settings/#post-apisettingsemailtest-send","title":"POST /api/settings/email/test-send","text":"

Send test email to verify SMTP configuration (SUPER_ADMIN only).

Request Headers:

Authorization: Bearer <access_token>\nContent-Type: application/json\n

Request Body (Optional):

{\n  \"to\": \"test@example.com\"\n}\n

If to is not provided, uses testEmailRecipient from settings or defaults to admin@cmlite.org.

Example Request:

curl -X POST \\\n  -H \"Authorization: Bearer <token>\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"to\":\"test@example.com\"}' \\\n  http://api.cmlite.org/api/settings/email/test-send\n

Response (200 OK):

{\n  \"success\": true,\n  \"messageId\": \"<20260211120000.1.abcd1234@cmlite.org>\",\n  \"testMode\": false,\n  \"recipient\": \"test@example.com\"\n}\n

Response (Test Mode):

{\n  \"success\": true,\n  \"messageId\": \"test-mode-1234567890\",\n  \"testMode\": true,\n  \"recipient\": \"test@example.com\"\n}\n

Implementation:

router.post(\n  '/email/test-send',\n  authenticate,\n  requireRole(UserRole.SUPER_ADMIN),\n  async (req: Request, res: Response, next: NextFunction) => {\n    try {\n      const { to } = req.body as { to?: string };\n      const settings = await siteSettingsService.get();\n      const recipient = to || settings.testEmailRecipient || 'admin@cmlite.org';\n\n      const result = await emailService.sendEmail({\n        to: recipient,\n        subject: 'Changemaker Lite \u2014 Test Email',\n        html: `<h2>SMTP Test Successful</h2><p>This email confirms that your SMTP configuration is working correctly.</p><p>Sent at: ${new Date().toISOString()}</p>`,\n        text: `SMTP Test Successful\\n\\nThis email confirms that your SMTP configuration is working correctly.\\n\\nSent at: ${new Date().toISOString()}`,\n      });\n\n      res.json({\n        success: result.success,\n        messageId: result.messageId,\n        testMode: result.testMode,\n        recipient,\n      });\n    } catch (err) {\n      next(err);\n    }\n  }\n);\n

Test Mode:

If emailTestMode is true, emails are sent to MailHog instead of actual SMTP server:

"},{"location":"v2/backend/modules/settings/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/settings/#sitesettingsserviceget","title":"siteSettingsService.get()","text":"

Purpose: Get full settings with decrypted SMTP password (admin use).

Auto-Creation:

let settings = await prisma.siteSettings.findFirst();\nif (!settings) {\n  settings = await prisma.siteSettings.create({ data: {} });\n}\nreturn decryptSettings(settings);\n
"},{"location":"v2/backend/modules/settings/#sitesettingsservicegetpublic","title":"siteSettingsService.getPublic()","text":"

Purpose: Get settings without sensitive SMTP fields (public use).

Stripped Fields:

"},{"location":"v2/backend/modules/settings/#sitesettingsserviceupdatedata","title":"siteSettingsService.update(data)","text":"

Purpose: Update settings with encryption for sensitive fields.

Encryption:

const toWrite = { ...data };\nfor (const field of ENCRYPTED_FIELDS) {\n  if (field in toWrite && typeof toWrite[field] === 'string' && toWrite[field]) {\n    (toWrite as Record<string, unknown>)[field] = encrypt(toWrite[field] as string);\n  }\n}\n

Upsert Logic:

const existing = await prisma.siteSettings.findFirst();\nlet settings: SiteSettings;\nif (existing) {\n  settings = await prisma.siteSettings.update({\n    where: { id: existing.id },\n    data: toWrite,\n  });\n} else {\n  settings = await prisma.siteSettings.create({ data: toWrite });\n}\nreturn decryptSettings(settings);\n
"},{"location":"v2/backend/modules/settings/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/settings/#frontend-load-public-settings","title":"Frontend: Load Public Settings","text":"
import axios from 'axios';\n\nconst loadSettings = async () => {\n  const { data } = await axios.get('/api/settings');\n\n  // Apply theme to Ant Design ConfigProvider\n  document.title = data.organizationName;\n  if (data.organizationFaviconUrl) {\n    const link = document.querySelector(\"link[rel='icon']\") as HTMLLinkElement;\n    if (link) link.href = data.organizationFaviconUrl;\n  }\n\n  return data;\n};\n
"},{"location":"v2/backend/modules/settings/#admin-update-settings","title":"Admin: Update Settings","text":"
import { api } from '@/lib/api';\n\nconst updateSettings = async (updates: Partial<SiteSettings>) => {\n  const { data } = await api.put('/api/settings', updates);\n\n  message.success('Settings updated successfully');\n  return data;\n};\n\n// Usage\nawait updateSettings({\n  organizationName: 'My Campaign',\n  publicColorPrimary: '#ff6b6b',\n  enableNewsletter: true,\n});\n
"},{"location":"v2/backend/modules/settings/#admin-test-smtp-connection","title":"Admin: Test SMTP Connection","text":"
import { api } from '@/lib/api';\n\nconst testSmtpConnection = async () => {\n  try {\n    const { data } = await api.post('/api/settings/email/test-connection');\n\n    if (data.success) {\n      message.success('SMTP connection verified');\n    } else {\n      message.error('SMTP connection failed');\n    }\n\n    return data.success;\n  } catch (error) {\n    message.error('Failed to test SMTP connection');\n    return false;\n  }\n};\n
"},{"location":"v2/backend/modules/settings/#admin-send-test-email","title":"Admin: Send Test Email","text":"
import { api } from '@/lib/api';\n\nconst sendTestEmail = async (recipient?: string) => {\n  try {\n    const { data } = await api.post('/api/settings/email/test-send', {\n      to: recipient,\n    });\n\n    if (data.success) {\n      if (data.testMode) {\n        message.success(`Test email sent (MailHog mode) to ${data.recipient}`);\n      } else {\n        message.success(`Test email sent to ${data.recipient}`);\n      }\n    }\n\n    return data;\n  } catch (error) {\n    message.error('Failed to send test email');\n    throw error;\n  }\n};\n
"},{"location":"v2/backend/modules/settings/#validation-schema","title":"Validation Schema","text":"
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Must be a hex color (e.g. #ff00ff)');\n\nexport const updateSiteSettingsSchema = z.object({\n  // Organization\n  organizationName: z.string().min(1).max(100).optional(),\n  organizationShortName: z.string().min(1).max(10).optional(),\n  organizationLogoUrl: z.string().url().nullable().optional().or(z.literal('')),\n  organizationFaviconUrl: z.string().url().nullable().optional().or(z.literal('')),\n\n  // Admin theme\n  adminColorPrimary: hexColor.optional(),\n  adminColorBgBase: hexColor.optional(),\n\n  // Public theme\n  publicColorPrimary: hexColor.optional(),\n  publicColorBgBase: hexColor.optional(),\n  publicColorBgContainer: hexColor.optional(),\n  publicHeaderGradient: z.string().max(500).optional(),\n\n  // Text\n  footerText: z.string().max(200).optional(),\n  loginSubtitle: z.string().max(50).optional(),\n\n  // Email branding\n  emailFromName: z.string().min(1).max(100).optional(),\n\n  // SMTP configuration\n  smtpHost: z.string().max(255).optional(),\n  smtpPort: z.number().int().min(0).max(65535).optional(),\n  smtpUser: z.string().max(255).optional(),\n  smtpPass: z.string().max(500).optional(),\n  smtpFromAddress: z.string().max(255).optional(),\n  smtpActiveProvider: z.enum(['mailhog', 'production']).optional(),\n  emailTestMode: z.boolean().optional(),\n  testEmailRecipient: z.string().max(255).optional(),\n\n  // Feature toggles\n  enableInfluence: z.boolean().optional(),\n  enableMap: z.boolean().optional(),\n  enableNewsletter: z.boolean().optional(),\n  enableLandingPages: z.boolean().optional(),\n});\n
"},{"location":"v2/backend/modules/settings/#encryption","title":"Encryption","text":""},{"location":"v2/backend/modules/settings/#aes-256-gcm-encryption","title":"AES-256-GCM Encryption","text":"

The smtpPass field is encrypted at rest using AES-256-GCM (authenticated encryption).

Environment Configuration:

ENCRYPTION_KEY=<32-byte-hex>  # Must NOT reuse JWT secrets\n

Generate Encryption Key:

openssl rand -hex 32\n

Encryption Utility:

import crypto from 'crypto';\n\nconst ALGORITHM = 'aes-256-gcm';\nconst IV_LENGTH = 12;\nconst TAG_LENGTH = 16;\nconst SALT_LENGTH = 32;\nconst KEY_LENGTH = 32;\n\nexport function encrypt(plaintext: string): string {\n  const iv = crypto.randomBytes(IV_LENGTH);\n  const salt = crypto.randomBytes(SALT_LENGTH);\n\n  const key = crypto.pbkdf2Sync(env.ENCRYPTION_KEY, salt, 100000, KEY_LENGTH, 'sha256');\n  const cipher = crypto.createCipheriv(ALGORITHM, key, iv);\n\n  let encrypted = cipher.update(plaintext, 'utf8', 'base64');\n  encrypted += cipher.final('base64');\n\n  const tag = cipher.getAuthTag();\n\n  // Format: iv:salt:tag:ciphertext\n  return `${iv.toString('base64')}:${salt.toString('base64')}:${tag.toString('base64')}:${encrypted}`;\n}\n\nexport function decrypt(ciphertext: string): string {\n  const parts = ciphertext.split(':');\n  if (parts.length !== 4) throw new Error('Invalid ciphertext format');\n\n  const [ivB64, saltB64, tagB64, encrypted] = parts;\n  const iv = Buffer.from(ivB64, 'base64');\n  const salt = Buffer.from(saltB64, 'base64');\n  const tag = Buffer.from(tagB64, 'base64');\n\n  const key = crypto.pbkdf2Sync(env.ENCRYPTION_KEY, salt, 100000, KEY_LENGTH, 'sha256');\n  const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);\n\n  decipher.setAuthTag(tag);\n\n  let decrypted = decipher.update(encrypted, 'base64', 'utf8');\n  decrypted += decipher.final('utf8');\n\n  return decrypted;\n}\n
"},{"location":"v2/backend/modules/settings/#feature-toggles","title":"Feature Toggles","text":"Toggle Default Description enableInfluence true Advocacy campaigns + response wall enableMap true Location mapping + canvassing enableNewsletter false Listmonk integration enableLandingPages true GrapesJS page builder

Frontend Usage:

const settings = await loadSettings();\n\nif (settings.enableInfluence) {\n  // Show Influence menu items\n}\n\nif (settings.enableMap) {\n  // Show Map menu items\n}\n
"},{"location":"v2/backend/modules/settings/#environment-configuration","title":"Environment Configuration","text":"

Required environment variables:

# Encryption (for smtpPass field)\nENCRYPTION_KEY=<32-byte-hex>  # Must differ from JWT secrets\n\n# Database\nDATABASE_URL=postgresql://user:password@localhost:5432/changemaker_v2\n
"},{"location":"v2/backend/modules/settings/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/backend/modules/shifts/","title":"Shifts Module","text":""},{"location":"v2/backend/modules/shifts/#overview","title":"Overview","text":"

The Shifts module manages volunteer shift scheduling with public signup capabilities. It provides comprehensive CRUD operations for shift management, volunteer signup tracking, and automatic status updates based on capacity. The module includes three separate routers for admin management, authenticated volunteer portal access, and public signup flows.

Key Features:

"},{"location":"v2/backend/modules/shifts/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/map/shifts/shifts.routes.ts 3 routers: admin, volunteer, public (242 lines) api/src/modules/map/shifts/shifts.service.ts Shift business logic with signup flows (754 lines) api/src/modules/map/shifts/shifts.schemas.ts Zod validation schemas (55 lines)"},{"location":"v2/backend/modules/shifts/#database-models","title":"Database Models","text":"
model Shift {\n  id                String      @id @default(cuid())\n  title             String\n  description       String?     @db.Text\n  date              DateTime    @db.Date\n  startTime         String      // HH:MM format\n  endTime           String      // HH:MM format\n  location          String?\n  maxVolunteers     Int\n  currentVolunteers Int         @default(0)\n  status            ShiftStatus @default(OPEN)\n  isPublic          Boolean     @default(false)\n  cutId             String?\n  cut               Cut?        @relation(fields: [cutId], references: [id], onDelete: SetNull)\n  createdBy         String?\n  createdAt         DateTime    @default(now())\n  updatedAt         DateTime    @updatedAt\n\n  signups           ShiftSignup[]\n  canvassVisits     CanvassVisit[]\n  canvassSessions   CanvassSession[]\n\n  @@index([cutId])\n  @@map(\"shifts\")\n}\n\nenum ShiftStatus {\n  OPEN       // Accepting signups\n  FULL       // Max capacity reached\n  CANCELLED  // Shift cancelled\n}\n\nmodel ShiftSignup {\n  id           String       @id @default(cuid())\n  shiftId      String\n  shift        Shift        @relation(fields: [shiftId], references: [id], onDelete: Cascade)\n  shiftTitle   String?\n  userId       String?\n  user         User?        @relation(fields: [userId], references: [id], onDelete: SetNull)\n  userEmail    String\n  userName     String?\n  userPhone    String?\n  signupDate   DateTime     @default(now())\n  status       SignupStatus @default(CONFIRMED)\n  signupSource SignupSource @default(AUTHENTICATED)\n\n  @@unique([shiftId, userEmail])\n  @@index([shiftId])\n  @@map(\"shift_signups\")\n}\n\nenum SignupStatus {\n  CONFIRMED  // Active signup\n  CANCELLED  // Cancelled (can be re-activated)\n}\n\nenum SignupSource {\n  AUTHENTICATED  // Logged-in user signup\n  PUBLIC         // Anonymous public signup\n  ADMIN          // Added by admin\n}\n

Key Relationships:

Unique Constraints:

"},{"location":"v2/backend/modules/shifts/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/shifts/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":"Method Path Auth Description GET /api/map/shifts MAP_ADMIN List paginated shifts GET /api/map/shifts/stats MAP_ADMIN Shift statistics GET /api/map/shifts/:id MAP_ADMIN Get shift with signups POST /api/map/shifts MAP_ADMIN Create shift PUT /api/map/shifts/:id MAP_ADMIN Update shift DELETE /api/map/shifts/:id MAP_ADMIN Delete shift POST /api/map/shifts/:id/signups MAP_ADMIN Add volunteer signup DELETE /api/map/shifts/:id/signups/:signupId MAP_ADMIN Remove signup POST /api/map/shifts/:id/email-details MAP_ADMIN Email all volunteers

Admin Roles: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/backend/modules/shifts/#volunteer-endpoints-authentication-required","title":"Volunteer Endpoints (Authentication Required)","text":"Method Path Auth Description GET /api/map/shifts/volunteer/upcoming Any logged-in Upcoming shifts with signup status GET /api/map/shifts/volunteer/my-signups Any logged-in Own confirmed signups POST /api/map/shifts/volunteer/:id/signup Any logged-in Sign up for shift DELETE /api/map/shifts/volunteer/:id/signup Any logged-in Cancel own signup"},{"location":"v2/backend/modules/shifts/#public-endpoints-no-authentication","title":"Public Endpoints (No Authentication)","text":"Method Path Auth Description GET /api/map/shifts/public None List public upcoming shifts POST /api/map/shifts/public/:id/signup None Public signup (creates temp user if needed)"},{"location":"v2/backend/modules/shifts/#admin-endpoint-details","title":"Admin Endpoint Details","text":""},{"location":"v2/backend/modules/shifts/#get-apimapshifts","title":"GET /api/map/shifts","text":"

List shifts with pagination, search, and filtering.

Query Parameters:

Parameter Type Required Default Description page number No 1 Page number limit number No 20 Results per page (max 100) search string No - Search title or location status ShiftStatus No - Filter by status upcoming boolean No - Filter to shifts with date >= today sortBy enum No date Sort field: date, createdAt, title sortOrder enum No desc Sort direction: asc, desc

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/shifts?upcoming=true&status=OPEN&page=1&limit=10\"\n

Response (200 OK):

{\n  \"shifts\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"title\": \"Door Knocking \u2014 Ward 5\",\n      \"description\": \"Canvassing residential areas in Ward 5. Meet at campaign office.\",\n      \"date\": \"2026-02-15T00:00:00.000Z\",\n      \"startTime\": \"10:00\",\n      \"endTime\": \"14:00\",\n      \"location\": \"123 Main St (Campaign Office)\",\n      \"maxVolunteers\": 15,\n      \"currentVolunteers\": 8,\n      \"status\": \"OPEN\",\n      \"isPublic\": true,\n      \"cutId\": \"clx0987654321\",\n      \"cut\": {\n        \"id\": \"clx0987654321\",\n        \"name\": \"Ward 5 Residential\"\n      },\n      \"createdBy\": \"clx1111111111\",\n      \"createdAt\": \"2026-02-01T12:00:00.000Z\",\n      \"updatedAt\": \"2026-02-11T14:30:00.000Z\",\n      \"_count\": {\n        \"signups\": 8\n      }\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 10,\n    \"total\": 23,\n    \"totalPages\": 3\n  }\n}\n
"},{"location":"v2/backend/modules/shifts/#get-apimapshiftsstats","title":"GET /api/map/shifts/stats","text":"

Get shift statistics.

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/shifts/stats\"\n

Response (200 OK):

{\n  \"total\": 45,\n  \"open\": 12,\n  \"full\": 3,\n  \"cancelled\": 2,\n  \"upcoming\": 15,\n  \"totalSignups\": 287\n}\n
"},{"location":"v2/backend/modules/shifts/#get-apimapshiftsid","title":"GET /api/map/shifts/:id","text":"

Get single shift with signups list.

Path Parameters:

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/shifts/clx1234567890\"\n

Response (200 OK):

{\n  \"id\": \"clx1234567890\",\n  \"title\": \"Door Knocking \u2014 Ward 5\",\n  \"description\": \"Canvassing residential areas in Ward 5\",\n  \"date\": \"2026-02-15T00:00:00.000Z\",\n  \"startTime\": \"10:00\",\n  \"endTime\": \"14:00\",\n  \"location\": \"123 Main St\",\n  \"maxVolunteers\": 15,\n  \"currentVolunteers\": 8,\n  \"status\": \"OPEN\",\n  \"isPublic\": true,\n  \"cutId\": \"clx0987654321\",\n  \"cut\": {\n    \"id\": \"clx0987654321\",\n    \"name\": \"Ward 5 Residential\"\n  },\n  \"signups\": [\n    {\n      \"id\": \"clx2222222222\",\n      \"shiftId\": \"clx1234567890\",\n      \"shiftTitle\": \"Door Knocking \u2014 Ward 5\",\n      \"userId\": \"clx3333333333\",\n      \"user\": {\n        \"id\": \"clx3333333333\",\n        \"email\": \"volunteer@example.com\",\n        \"name\": \"Jane Volunteer\",\n        \"phone\": \"+1234567890\"\n      },\n      \"userEmail\": \"volunteer@example.com\",\n      \"userName\": \"Jane Volunteer\",\n      \"userPhone\": \"+1234567890\",\n      \"signupDate\": \"2026-02-05T10:30:00.000Z\",\n      \"status\": \"CONFIRMED\",\n      \"signupSource\": \"PUBLIC\"\n    }\n  ],\n  \"_count\": {\n    \"signups\": 8\n  }\n}\n

Error Responses:

"},{"location":"v2/backend/modules/shifts/#post-apimapshifts","title":"POST /api/map/shifts","text":"

Create new shift.

Request Body:

{\n  \"title\": \"Door Knocking \u2014 Ward 5\",\n  \"description\": \"Canvassing residential areas in Ward 5. Meet at campaign office.\",\n  \"date\": \"2026-02-15\",\n  \"startTime\": \"10:00\",\n  \"endTime\": \"14:00\",\n  \"location\": \"123 Main St (Campaign Office)\",\n  \"maxVolunteers\": 15,\n  \"isPublic\": true,\n  \"cutId\": \"clx0987654321\"\n}\n

Response (201 Created):

Returns created shift object (same format as GET).

Validation:

"},{"location":"v2/backend/modules/shifts/#put-apimapshiftsid","title":"PUT /api/map/shifts/:id","text":"

Update shift. Auto-updates status if capacity changes.

Request Body (Partial):

{\n  \"maxVolunteers\": 20,\n  \"status\": \"OPEN\"\n}\n

Response (200 OK):

Returns updated shift object.

Auto-Status Logic:

// When maxVolunteers is updated:\nif (currentVolunteers >= newMaxVolunteers && status === OPEN) {\n  status = FULL;\n} else if (currentVolunteers < newMaxVolunteers && status === FULL) {\n  status = OPEN;\n}\n
"},{"location":"v2/backend/modules/shifts/#delete-apimapshiftsid","title":"DELETE /api/map/shifts/:id","text":"

Delete shift. Cascade deletes all signups.

Response (204 No Content):

No response body.

"},{"location":"v2/backend/modules/shifts/#post-apimapshiftsidsignups","title":"POST /api/map/shifts/:id/signups","text":"

Admin add volunteer signup.

Request Body:

{\n  \"userEmail\": \"volunteer@example.com\",\n  \"userName\": \"Jane Volunteer\"\n}\n

Response (201 Created):

{\n  \"id\": \"clx2222222222\",\n  \"shiftId\": \"clx1234567890\",\n  \"shiftTitle\": \"Door Knocking \u2014 Ward 5\",\n  \"userId\": \"clx3333333333\",\n  \"userEmail\": \"volunteer@example.com\",\n  \"userName\": \"Jane Volunteer\",\n  \"userPhone\": null,\n  \"signupDate\": \"2026-02-11T15:00:00.000Z\",\n  \"status\": \"CONFIRMED\",\n  \"signupSource\": \"ADMIN\"\n}\n

Behavior:

Error Responses:

"},{"location":"v2/backend/modules/shifts/#delete-apimapshiftsidsignupssignupid","title":"DELETE /api/map/shifts/:id/signups/:signupId","text":"

Admin remove volunteer signup.

Path Parameters:

Response (204 No Content):

No response body.

Behavior:

Error Responses:

"},{"location":"v2/backend/modules/shifts/#post-apimapshiftsidemail-details","title":"POST /api/map/shifts/:id/email-details","text":"

Email shift details to all confirmed volunteers.

Example Request:

curl -X POST \\\n  -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/shifts/clx1234567890/email-details\"\n

Response (200 OK):

{\n  \"sent\": 8,\n  \"failed\": 0\n}\n

Email Template:

Uses shift-details.html and shift-details.txt templates with variables:

"},{"location":"v2/backend/modules/shifts/#volunteer-endpoint-details","title":"Volunteer Endpoint Details","text":""},{"location":"v2/backend/modules/shifts/#get-apimapshiftsvolunteerupcoming","title":"GET /api/map/shifts/volunteer/upcoming","text":"

Get upcoming public shifts with signup status for current user.

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/shifts/volunteer/upcoming\"\n

Response (200 OK):

[\n  {\n    \"id\": \"clx1234567890\",\n    \"title\": \"Door Knocking \u2014 Ward 5\",\n    \"description\": \"Canvassing residential areas\",\n    \"date\": \"2026-02-15T00:00:00.000Z\",\n    \"startTime\": \"10:00\",\n    \"endTime\": \"14:00\",\n    \"location\": \"123 Main St\",\n    \"maxVolunteers\": 15,\n    \"currentVolunteers\": 8,\n    \"status\": \"OPEN\",\n    \"isSignedUp\": true\n  },\n  {\n    \"id\": \"clx9876543210\",\n    \"title\": \"Phone Banking\",\n    \"description\": \"Call voters for GOTV\",\n    \"date\": \"2026-02-16T00:00:00.000Z\",\n    \"startTime\": \"18:00\",\n    \"endTime\": \"20:00\",\n    \"location\": \"Virtual (Zoom)\",\n    \"maxVolunteers\": 25,\n    \"currentVolunteers\": 12,\n    \"status\": \"OPEN\",\n    \"isSignedUp\": false\n  }\n]\n

Filtering:

"},{"location":"v2/backend/modules/shifts/#get-apimapshiftsvolunteermy-signups","title":"GET /api/map/shifts/volunteer/my-signups","text":"

Get current user's confirmed signups for upcoming shifts.

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/shifts/volunteer/my-signups\"\n

Response (200 OK):

[\n  {\n    \"id\": \"clx2222222222\",\n    \"shiftId\": \"clx1234567890\",\n    \"shiftTitle\": \"Door Knocking \u2014 Ward 5\",\n    \"userId\": \"clx3333333333\",\n    \"userEmail\": \"volunteer@example.com\",\n    \"userName\": \"Jane Volunteer\",\n    \"userPhone\": \"+1234567890\",\n    \"signupDate\": \"2026-02-05T10:30:00.000Z\",\n    \"status\": \"CONFIRMED\",\n    \"signupSource\": \"PUBLIC\",\n    \"shift\": {\n      \"id\": \"clx1234567890\",\n      \"title\": \"Door Knocking \u2014 Ward 5\",\n      \"description\": \"Canvassing residential areas\",\n      \"date\": \"2026-02-15T00:00:00.000Z\",\n      \"startTime\": \"10:00\",\n      \"endTime\": \"14:00\",\n      \"location\": \"123 Main St\",\n      \"maxVolunteers\": 15,\n      \"currentVolunteers\": 8,\n      \"status\": \"OPEN\"\n    }\n  }\n]\n

Filtering:

"},{"location":"v2/backend/modules/shifts/#post-apimapshiftsvolunteeridsignup","title":"POST /api/map/shifts/volunteer/:id/signup","text":"

Authenticated user signs up for shift.

Path Parameters:

Rate Limiting:

5 requests/min per IP (shiftSignupRateLimit middleware)

Example Request:

curl -X POST \\\n  -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/shifts/volunteer/clx1234567890/signup\"\n

Response (201 Created):

{\n  \"id\": \"clx2222222222\",\n  \"shiftId\": \"clx1234567890\",\n  \"shiftTitle\": \"Door Knocking \u2014 Ward 5\",\n  \"userId\": \"clx3333333333\",\n  \"userEmail\": \"volunteer@example.com\",\n  \"userName\": \"Jane Volunteer\",\n  \"userPhone\": \"+1234567890\",\n  \"signupDate\": \"2026-02-11T15:00:00.000Z\",\n  \"status\": \"CONFIRMED\",\n  \"signupSource\": \"AUTHENTICATED\"\n}\n

Validation:

Behavior:

Error Responses:

"},{"location":"v2/backend/modules/shifts/#delete-apimapshiftsvolunteeridsignup","title":"DELETE /api/map/shifts/volunteer/:id/signup","text":"

Cancel own signup.

Path Parameters:

Example Request:

curl -X DELETE \\\n  -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/shifts/volunteer/clx1234567890/signup\"\n

Response (204 No Content):

No response body.

Behavior:

Error Responses:

"},{"location":"v2/backend/modules/shifts/#public-endpoint-details","title":"Public Endpoint Details","text":""},{"location":"v2/backend/modules/shifts/#get-apimapshiftspublic","title":"GET /api/map/shifts/public","text":"

List public upcoming shifts (no auth required).

Example Request:

curl http://api.cmlite.org/api/map/shifts/public\n

Response (200 OK):

[\n  {\n    \"id\": \"clx1234567890\",\n    \"title\": \"Door Knocking \u2014 Ward 5\",\n    \"description\": \"Canvassing residential areas in Ward 5\",\n    \"date\": \"2026-02-15T00:00:00.000Z\",\n    \"startTime\": \"10:00\",\n    \"endTime\": \"14:00\",\n    \"location\": \"123 Main St\",\n    \"maxVolunteers\": 15,\n    \"currentVolunteers\": 8,\n    \"status\": \"OPEN\"\n  }\n]\n

Filtering:

"},{"location":"v2/backend/modules/shifts/#post-apimapshiftspublicidsignup","title":"POST /api/map/shifts/public/:id/signup","text":"

Public signup with temporary user creation.

Path Parameters:

Rate Limiting:

5 requests/min per IP (shiftSignupRateLimit middleware)

Request Body:

{\n  \"email\": \"newvolunteer@example.com\",\n  \"name\": \"John Doe\",\n  \"phone\": \"+1234567890\"\n}\n

Response (201 Created):

{\n  \"signup\": {\n    \"id\": \"clx2222222222\",\n    \"shiftId\": \"clx1234567890\",\n    \"shiftTitle\": \"Door Knocking \u2014 Ward 5\",\n    \"userId\": \"clx4444444444\",\n    \"userEmail\": \"newvolunteer@example.com\",\n    \"userName\": \"John Doe\",\n    \"userPhone\": \"+1234567890\",\n    \"signupDate\": \"2026-02-11T15:00:00.000Z\",\n    \"status\": \"CONFIRMED\",\n    \"signupSource\": \"PUBLIC\"\n  },\n  \"isNewUser\": true\n}\n

Validation:

Behavior \u2014 New User:

If email does not exist in database:

  1. Generate readable password:

    const adjectives = ['Blue', 'Red', 'Green', 'Swift', 'Bright', 'Bold', 'Calm', 'Fair'];\nconst nouns = ['Eagle', 'River', 'Mountain', 'Star', 'Forest', 'Lake', 'Wolf', 'Hawk'];\n\nfunction generateReadablePassword(): string {\n  const adj = adjectives[Math.floor(Math.random() * adjectives.length)];\n  const noun = nouns[Math.floor(Math.random() * nouns.length)];\n  const num = Math.floor(Math.random() * 90) + 10;\n  return `${adj}${noun}${num}`;  // e.g., \"BlueEagle42\"\n}\n

  2. Create TEMP user:

    const hashedPassword = await bcrypt.hash(tempPassword, 12);\nconst shiftDate = new Date(shift.date);\nshiftDate.setDate(shiftDate.getDate() + 1);  // Expires day after shift\n\nconst user = await prisma.user.create({\n  data: {\n    email: data.email,\n    password: hashedPassword,\n    name: data.name,\n    phone: data.phone,\n    role: 'TEMP',\n    createdVia: 'PUBLIC_SHIFT_SIGNUP',\n    expiresAt: shiftDate,\n  },\n});\n

  3. Send confirmation email with temp password:

    const vars = {\n  USER_NAME: data.name,\n  USER_EMAIL: data.email,\n  SHIFT_TITLE: shift.title,\n  SHIFT_DATE: '...',\n  SHIFT_TIME: '...',\n  SHIFT_LOCATION: shift.location || 'TBD',\n  IS_NEW_USER: 'true',\n  TEMP_PASSWORD: tempPassword,  // Only included for new users\n  LOGIN_URL: `${siteUrl}/login`,\n  ORGANIZATION_NAME: orgName,\n};\n

Behavior \u2014 Existing User:

If email exists in database:

Behavior \u2014 Re-activation:

If cancelled signup exists:

Transaction:

Error Responses:

"},{"location":"v2/backend/modules/shifts/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/shifts/#shiftsservicefindallfilters","title":"shiftsService.findAll(filters)","text":"

List shifts with pagination, search, and filtering.

Usage:

import { shiftsService } from './shifts.service';\n\nconst result = await shiftsService.findAll({\n  page: 1,\n  limit: 20,\n  search: 'ward 5',\n  status: ShiftStatus.OPEN,\n  upcoming: true,\n  sortBy: 'date',\n  sortOrder: 'asc',\n});\n\nconsole.log(result.shifts.length);  // Array of shifts\nconsole.log(result.pagination);     // { page, limit, total, totalPages }\n

Search Behavior:

if (search) {\n  where.OR = [\n    { title: { contains: search, mode: 'insensitive' } },\n    { location: { contains: search, mode: 'insensitive' } },\n  ];\n}\n
"},{"location":"v2/backend/modules/shifts/#shiftsservicefindbyidid","title":"shiftsService.findById(id)","text":"

Get single shift with signups list.

Usage:

const shift = await shiftsService.findById('clx1234567890');\nconsole.log(shift.signups.length);  // Confirmed signups only\nconsole.log(shift.cut?.name);       // Cut name if associated\n

Throws:

"},{"location":"v2/backend/modules/shifts/#shiftsservicecreatedata-userid","title":"shiftsService.create(data, userId)","text":"

Create shift.

Usage:

const shift = await shiftsService.create({\n  title: 'Door Knocking \u2014 Ward 5',\n  description: 'Canvassing residential areas',\n  date: '2026-02-15',\n  startTime: '10:00',\n  endTime: '14:00',\n  location: '123 Main St',\n  maxVolunteers: 15,\n  isPublic: true,\n  cutId: 'clx0987654321',\n}, req.user.id);\n
"},{"location":"v2/backend/modules/shifts/#shiftsserviceupdateid-data","title":"shiftsService.update(id, data)","text":"

Update shift with auto-status management.

Usage:

const shift = await shiftsService.update('clx1234567890', {\n  maxVolunteers: 20,\n});\n\n// If currentVolunteers was 15 and maxVolunteers was 15:\n// - Old status: FULL\n// - New status: OPEN (because 15 < 20)\n

Auto-Status Logic:

if (data.maxVolunteers !== undefined) {\n  if (existing.currentVolunteers >= data.maxVolunteers && existing.status === ShiftStatus.OPEN) {\n    updateData.status = ShiftStatus.FULL;\n  } else if (existing.currentVolunteers < data.maxVolunteers && existing.status === ShiftStatus.FULL) {\n    updateData.status = ShiftStatus.OPEN;\n  }\n}\n
"},{"location":"v2/backend/modules/shifts/#shiftsserviceaddsignupshiftid-data","title":"shiftsService.addSignup(shiftId, data)","text":"

Admin add volunteer signup.

Usage:

const signup = await shiftsService.addSignup('clx1234567890', {\n  userEmail: 'volunteer@example.com',\n  userName: 'Jane Volunteer',\n});\n\nconsole.log(signup.signupSource);  // 'ADMIN'\n

Behavior:

Throws:

"},{"location":"v2/backend/modules/shifts/#shiftsservicepublicsignupshiftid-data","title":"shiftsService.publicSignup(shiftId, data)","text":"

Public signup with temp user creation.

Usage:

const result = await shiftsService.publicSignup('clx1234567890', {\n  email: 'newuser@example.com',\n  name: 'John Doe',\n  phone: '+1234567890',\n});\n\nif (result.isNewUser) {\n  console.log('Created TEMP user with readable password');\n  console.log('Confirmation email sent with credentials');\n}\n

Temp User Expiry:

const shiftDate = new Date(shift.date);\nshiftDate.setDate(shiftDate.getDate() + 1);  // Expires day after shift\n

Email Template Variables:

const vars: Record<string, string> = {\n  USER_NAME: data.name,\n  USER_EMAIL: data.email,\n  SHIFT_TITLE: shift.title,\n  SHIFT_DATE: dateStr,\n  SHIFT_TIME: `${shift.startTime} \u2014 ${shift.endTime}`,\n  SHIFT_LOCATION: shift.location || 'TBD',\n  IS_NEW_USER: isNewUser ? 'true' : '',  // Conditional content in template\n  TEMP_PASSWORD: tempPassword || '',     // Only included for new users\n  LOGIN_URL: `${baseUrl}/login`,\n  ORGANIZATION_NAME: orgName,\n};\n

Metrics:

Records cm_shift_signups_total Prometheus counter.

Throws:

"},{"location":"v2/backend/modules/shifts/#shiftsserviceremovesignupsignupid","title":"shiftsService.removeSignup(signupId)","text":"

Cancel signup (admin).

Usage:

await shiftsService.removeSignup('clx2222222222');\n\n// Signup status \u2192 CANCELLED\n// currentVolunteers decremented\n// Shift status \u2192 OPEN\n

Atomic Transaction:

await prisma.$transaction([\n  prisma.shiftSignup.update({\n    where: { id: signupId },\n    data: { status: SignupStatus.CANCELLED },\n  }),\n  prisma.shift.update({\n    where: { id: signup.shiftId },\n    data: {\n      currentVolunteers: { decrement: 1 },\n      status: ShiftStatus.OPEN,\n    },\n  }),\n]);\n
"},{"location":"v2/backend/modules/shifts/#shiftsserviceemailshiftdetailsshiftid","title":"shiftsService.emailShiftDetails(shiftId)","text":"

Email shift details to all confirmed volunteers.

Usage:

const result = await shiftsService.emailShiftDetails('clx1234567890');\nconsole.log(`Sent: ${result.sent}, Failed: ${result.failed}`);\n

Email Template:

Uses shift-details.html and shift-details.txt with variables:

Error Handling:

Individual email failures are logged but do not stop batch processing.

"},{"location":"v2/backend/modules/shifts/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/shifts/#create-shift-schema","title":"Create Shift Schema","text":"
export const createShiftSchema = z.object({\n  title: z.string().min(1, 'Title is required'),\n  description: z.string().optional(),\n  date: z.string().regex(/^\\d{4}-\\d{2}-\\d{2}$/, 'Date must be YYYY-MM-DD'),\n  startTime: z.string().regex(/^\\d{2}:\\d{2}$/, 'Start time must be HH:MM'),\n  endTime: z.string().regex(/^\\d{2}:\\d{2}$/, 'End time must be HH:MM'),\n  location: z.string().optional(),\n  maxVolunteers: z.number().int().min(1, 'Must have at least 1 volunteer spot'),\n  isPublic: z.boolean().optional().default(false),\n  cutId: z.string().optional(),\n});\n

Example Valid Input:

{\n  \"title\": \"Door Knocking \u2014 Ward 5\",\n  \"description\": \"Canvassing residential areas\",\n  \"date\": \"2026-02-15\",\n  \"startTime\": \"10:00\",\n  \"endTime\": \"14:00\",\n  \"location\": \"123 Main St\",\n  \"maxVolunteers\": 15,\n  \"isPublic\": true,\n  \"cutId\": \"clx0987654321\"\n}\n
"},{"location":"v2/backend/modules/shifts/#update-shift-schema","title":"Update Shift Schema","text":"
export const updateShiftSchema = z.object({\n  title: z.string().min(1).optional(),\n  description: z.string().nullable().optional(),\n  date: z.string().regex(/^\\d{4}-\\d{2}-\\d{2}$/).optional(),\n  startTime: z.string().regex(/^\\d{2}:\\d{2}$/).optional(),\n  endTime: z.string().regex(/^\\d{2}:\\d{2}$/).optional(),\n  location: z.string().nullable().optional(),\n  maxVolunteers: z.number().int().min(1).optional(),\n  isPublic: z.boolean().optional(),\n  status: z.nativeEnum(ShiftStatus).optional(),\n  cutId: z.string().nullable().optional(),\n});\n

Partial Updates:

All fields optional. Only provided fields are updated.

"},{"location":"v2/backend/modules/shifts/#public-signup-schema","title":"Public Signup Schema","text":"
export const publicSignupSchema = z.object({\n  email: z.string().email('Valid email is required'),\n  name: z.string().min(1, 'Name is required'),\n  phone: z.string().optional(),\n});\n

Example Valid Input:

{\n  \"email\": \"volunteer@example.com\",\n  \"name\": \"Jane Volunteer\",\n  \"phone\": \"+1234567890\"\n}\n
"},{"location":"v2/backend/modules/shifts/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/shifts/#admin-create-shift-with-cut-association","title":"Admin: Create Shift with Cut Association","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst createShift = async () => {\n  try {\n    const { data } = await api.post('/api/map/shifts', {\n      title: 'Door Knocking \u2014 Ward 5',\n      description: 'Canvassing residential areas in Ward 5. Meet at campaign office.',\n      date: '2026-02-15',\n      startTime: '10:00',\n      endTime: '14:00',\n      location: '123 Main St (Campaign Office)',\n      maxVolunteers: 15,\n      isPublic: true,\n      cutId: 'clx0987654321',  // Associate with cut\n    });\n\n    message.success(`Shift created: ${data.title}`);\n    return data;\n  } catch (error) {\n    message.error('Failed to create shift');\n    throw error;\n  }\n};\n
"},{"location":"v2/backend/modules/shifts/#volunteer-sign-up-for-shift","title":"Volunteer: Sign Up for Shift","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst signUpForShift = async (shiftId: string) => {\n  try {\n    const { data } = await api.post(`/api/map/shifts/volunteer/${shiftId}/signup`);\n\n    message.success('Signed up successfully! Check your email for confirmation.');\n    return data;\n  } catch (error: any) {\n    if (error.response?.data?.code === 'SHIFT_FULL') {\n      message.error('This shift is full');\n    } else if (error.response?.data?.code === 'DUPLICATE_SIGNUP') {\n      message.warning('You are already signed up for this shift');\n    } else {\n      message.error('Failed to sign up');\n    }\n    throw error;\n  }\n};\n
"},{"location":"v2/backend/modules/shifts/#public-sign-up-without-account","title":"Public: Sign Up Without Account","text":"
import axios from 'axios';\n\nconst publicSignup = async (shiftId: string, formData: { email: string; name: string; phone?: string }) => {\n  try {\n    const { data } = await axios.post(\n      `/api/map/shifts/public/${shiftId}/signup`,\n      formData\n    );\n\n    if (data.isNewUser) {\n      alert(`Account created! Check your email for your temporary password.`);\n    } else {\n      alert('Signed up successfully!');\n    }\n\n    return data;\n  } catch (error: any) {\n    if (error.response?.status === 429) {\n      alert('Too many signups. Please try again in a minute.');\n    } else if (error.response?.data?.code === 'SHIFT_FULL') {\n      alert('This shift is full');\n    } else {\n      alert('Failed to sign up');\n    }\n    throw error;\n  }\n};\n
"},{"location":"v2/backend/modules/shifts/#admin-email-all-volunteers","title":"Admin: Email All Volunteers","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst emailAllVolunteers = async (shiftId: string) => {\n  try {\n    const { data } = await api.post(`/api/map/shifts/${shiftId}/email-details`);\n\n    message.success(`Sent ${data.sent} emails successfully. ${data.failed} failed.`);\n    return data;\n  } catch (error) {\n    message.error('Failed to send emails');\n    throw error;\n  }\n};\n
"},{"location":"v2/backend/modules/shifts/#frontend-integration","title":"Frontend Integration","text":"

The ShiftsPage component (admin/src/pages/ShiftsPage.tsx) provides:

State Management:

const [shifts, setShifts] = useState<Shift[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });\nconst [filters, setFilters] = useState({ search: '', status: null, upcoming: true });\nconst [signupsDrawerOpen, setSignupsDrawerOpen] = useState(false);\nconst [selectedShift, setSelectedShift] = useState<Shift | null>(null);\n

Volunteer Portal:

The VolunteerShiftsPage component (admin/src/pages/volunteer/VolunteerShiftsPage.tsx) provides:

Public Page:

The ShiftsPage component (admin/src/pages/public/ShiftsPage.tsx) provides:

"},{"location":"v2/backend/modules/shifts/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/backend/modules/shifts/#capacity-tracking","title":"Capacity Tracking","text":"

The currentVolunteers field is denormalized for performance:

// Instead of counting signups on every query:\nconst count = await prisma.shiftSignup.count({ where: { shiftId, status: 'CONFIRMED' } });\n\n// We maintain a counter:\ndata: {\n  currentVolunteers: { increment: 1 },  // On signup\n  currentVolunteers: { decrement: 1 },  // On cancel\n}\n

Pros:

Cons:

Consistency Checks:

Run periodic reconciliation:

UPDATE shifts\nSET \"currentVolunteers\" = (\n  SELECT COUNT(*) FROM shift_signups\n  WHERE \"shiftId\" = shifts.id AND status = 'CONFIRMED'\n)\nWHERE \"currentVolunteers\" != (\n  SELECT COUNT(*) FROM shift_signups\n  WHERE \"shiftId\" = shifts.id AND status = 'CONFIRMED'\n);\n
"},{"location":"v2/backend/modules/shifts/#unique-constraint-performance","title":"Unique Constraint Performance","text":"

The [shiftId, userEmail] unique constraint enables fast duplicate checks:

const existing = await prisma.shiftSignup.findUnique({\n  where: { shiftId_userEmail: { shiftId, userEmail: data.email } },\n});\n

Index Usage:

"},{"location":"v2/backend/modules/shifts/#rate-limiting","title":"Rate Limiting","text":"

The shiftSignupRateLimit middleware protects against signup spam:

// From api/src/middleware/rate-limit.ts\nexport const shiftSignupRateLimit = createRateLimiter({\n  windowMs: 60 * 1000,  // 1 minute\n  max: 5,               // 5 signups per minute\n  message: 'Too many signup requests. Please try again later.',\n});\n

Why 5/min?

"},{"location":"v2/backend/modules/shifts/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/shifts/#shift-status-not-updating-automatically","title":"Shift Status Not Updating Automatically","text":"

Problem:

Shift status stays FULL even after volunteer cancels.

Diagnosis:

Check transaction logic in removeSignup:

await prisma.$transaction([\n  prisma.shiftSignup.update({ /* ... */ }),\n  prisma.shift.update({\n    where: { id: signup.shiftId },\n    data: {\n      currentVolunteers: { decrement: 1 },\n      status: ShiftStatus.OPEN,  // Always set to OPEN on cancel\n    },\n  }),\n]);\n

Solution:

Status is always set to OPEN on cancel. If shift should remain FULL (e.g., still at capacity), check if another transaction occurred simultaneously.

"},{"location":"v2/backend/modules/shifts/#duplicate-signups","title":"Duplicate Signups","text":"

Problem:

User signed up twice for same shift.

Diagnosis:

Check unique constraint enforcement:

SELECT * FROM shift_signups\nWHERE \"shiftId\" = 'clx1234567890' AND \"userEmail\" = 'volunteer@example.com';\n

Possible Causes:

Solution:

"},{"location":"v2/backend/modules/shifts/#confirmation-emails-not-sending","title":"Confirmation Emails Not Sending","text":"

Problem:

Volunteers sign up but don't receive confirmation emails.

Diagnosis:

Check email service logs:

docker compose logs -f api | grep \"shift signup confirmation\"\n

Common Causes:

  1. MailHog mode enabled:

    EMAIL_TEST_MODE=true  # Emails go to MailHog, not SMTP\n

  2. SMTP misconfiguration:

    SMTP_HOST=smtp.gmail.com\nSMTP_PORT=587\nSMTP_USER=your-email@gmail.com\nSMTP_PASSWORD=your-app-password  # Must be app password, not account password\n

  3. Template missing:

    # Check template exists\nls api/src/templates/shift-signup-confirmation.html\nls api/src/templates/shift-signup-confirmation.txt\n

  4. Email service crash:

    try {\n  await emailService.sendEmail({ /* ... */ });\n} catch (err) {\n  logger.error('Failed to send shift signup confirmation email:', err);\n  // Signup succeeds even if email fails\n}\n

Solution:

"},{"location":"v2/backend/modules/shifts/#temp-users-not-expiring","title":"Temp Users Not Expiring","text":"

Problem:

TEMP users created via public signup still active long after shift.

Diagnosis:

Check expiresAt value:

SELECT id, email, role, \"expiresAt\", \"createdAt\"\nFROM users\nWHERE role = 'TEMP' AND \"expiresAt\" < NOW() AND status = 'ACTIVE';\n

Expected Behavior:

Solution:

Run cleanup script or add cron job:

// Expire temp users\nawait prisma.user.updateMany({\n  where: {\n    role: UserRole.TEMP,\n    expiresAt: { lte: new Date() },\n    status: { not: UserStatus.EXPIRED },\n  },\n  data: { status: UserStatus.EXPIRED },\n});\n
"},{"location":"v2/backend/modules/shifts/#rate-limit-too-strict","title":"Rate Limit Too Strict","text":"

Problem:

Users get rate-limited when legitimately signing up for multiple shifts.

Diagnosis:

Check rate limit config:

export const shiftSignupRateLimit = createRateLimiter({\n  windowMs: 60 * 1000,  // 1 minute\n  max: 5,               // 5 signups per minute\n});\n

Solution:

Increase limit if legitimate use case:

max: 10,  // Allow 10 signups per minute\n

Alternative:

Whitelist admin IPs:

skip: (req) => {\n  const ip = req.ip || req.connection.remoteAddress;\n  return ['127.0.0.1', '::1', ADMIN_IP].includes(ip);\n},\n
"},{"location":"v2/backend/modules/shifts/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/backend/modules/users/","title":"Users Module","text":""},{"location":"v2/backend/modules/users/#overview","title":"Overview","text":"

The Users module provides comprehensive user management with role-based access control, pagination, search, and filtering. It supports CRUD operations with granular permissions allowing admins to manage all users while regular users can only view/update their own profile.

Key Features:

"},{"location":"v2/backend/modules/users/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/users/users.routes.ts Express router with 5 CRUD endpoints api/src/modules/users/users.service.ts User management business logic api/src/modules/users/users.schemas.ts Zod validation schemas"},{"location":"v2/backend/modules/users/#database-model","title":"Database Model","text":"

The Users module uses the User model from the Auth module:

model User {\n  id              String         @id @default(cuid())\n  email           String         @unique\n  password        String\n  name            String?\n  phone           String?\n  role            UserRole       @default(USER)\n  status          UserStatus     @default(ACTIVE)\n  permissions     Json?\n  createdVia      String?        @default(\"web\")\n  emailVerified   Boolean        @default(false)\n  expiresAt       DateTime?      // For TEMP users\n  expireDays      Int?           // Days until expiration\n  lastLoginAt     DateTime?\n  createdAt       DateTime       @default(now())\n  updatedAt       DateTime       @updatedAt\n\n  // Relations\n  refreshTokens   RefreshToken[]\n  createdCampaigns Campaign[]    @relation(\"CreatedBy\")\n  createdLocations Location[]    @relation(\"CreatedBy\")\n  // ... other relations\n}\n\nenum UserRole {\n  SUPER_ADMIN      // Full system access\n  INFLUENCE_ADMIN  // Campaign management\n  MAP_ADMIN        // Location/canvass management\n  USER             // Standard authenticated user\n  TEMP             // Temporary user (e.g., shift signups)\n}\n\nenum UserStatus {\n  ACTIVE      // Normal operation\n  SUSPENDED   // Temporarily disabled\n  BANNED      // Permanently disabled\n}\n
"},{"location":"v2/backend/modules/users/#api-endpoints","title":"API Endpoints","text":"Method Path Auth Permissions Description GET /api/users Required Admin roles List users with pagination/filters GET /api/users/:id Required Admin or self Get user by ID POST /api/users Required Admin roles Create new user PUT /api/users/:id Required Admin or self Update user DELETE /api/users/:id Required Admin roles Delete user

Admin Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

"},{"location":"v2/backend/modules/users/#endpoint-details","title":"Endpoint Details","text":""},{"location":"v2/backend/modules/users/#get-apiusers","title":"GET /api/users","text":"

List users with pagination, search, and filtering (admin only).

Request Headers:

Authorization: Bearer <access_token>\n

Query Parameters:

Parameter Type Required Default Description page number No 1 Page number (1-indexed) limit number No 20 Results per page (max 100) search string No - Search email or name (case-insensitive) role UserRole No - Filter by role status UserStatus No - Filter by status

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/users?page=1&limit=20&search=john&role=USER&status=ACTIVE\"\n

Response (200 OK):

{\n  \"users\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"email\": \"john.doe@example.com\",\n      \"name\": \"John Doe\",\n      \"phone\": \"+1234567890\",\n      \"role\": \"USER\",\n      \"status\": \"ACTIVE\",\n      \"permissions\": null,\n      \"createdVia\": \"web\",\n      \"expiresAt\": null,\n      \"expireDays\": null,\n      \"lastLoginAt\": \"2026-02-11T12:00:00.000Z\",\n      \"emailVerified\": true,\n      \"createdAt\": \"2026-02-01T12:00:00.000Z\",\n      \"updatedAt\": \"2026-02-11T12:00:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 150,\n    \"totalPages\": 8\n  }\n}\n

Implementation:

router.get(\n  '/',\n  requireRole(...ADMIN_ROLES),\n  validate(listUsersSchema, 'query'),\n  async (req: Request, res: Response, next: NextFunction) => {\n    try {\n      const result = await usersService.findAll(req.query as any);\n      res.json(result);\n    } catch (err) {\n      next(err);\n    }\n  }\n);\n

Search Logic:

if (search) {\n  where.OR = [\n    { email: { contains: search, mode: 'insensitive' } },\n    { name: { contains: search, mode: 'insensitive' } },\n  ];\n}\n
"},{"location":"v2/backend/modules/users/#get-apiusersid","title":"GET /api/users/:id","text":"

Get user by ID. Admins can view any user, regular users can only view themselves.

Request Headers:

Authorization: Bearer <access_token>\n

Path Parameters:

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/users/clx1234567890\"\n

Response (200 OK):

{\n  \"id\": \"clx1234567890\",\n  \"email\": \"john.doe@example.com\",\n  \"name\": \"John Doe\",\n  \"phone\": \"+1234567890\",\n  \"role\": \"USER\",\n  \"status\": \"ACTIVE\",\n  \"permissions\": null,\n  \"createdVia\": \"web\",\n  \"expiresAt\": null,\n  \"expireDays\": null,\n  \"lastLoginAt\": \"2026-02-11T12:00:00.000Z\",\n  \"emailVerified\": true,\n  \"createdAt\": \"2026-02-01T12:00:00.000Z\",\n  \"updatedAt\": \"2026-02-11T12:00:00.000Z\"\n}\n

Error Responses:

Permission Logic:

const isAdmin = ADMIN_ROLES.includes(req.user!.role);\nconst isSelf = req.user!.id === id;\n\nif (!isAdmin && !isSelf) {\n  res.status(403).json({ error: { message: 'Insufficient permissions', code: 'FORBIDDEN' } });\n  return;\n}\n
"},{"location":"v2/backend/modules/users/#post-apiusers","title":"POST /api/users","text":"

Create new user account (admin only). Unlike public registration, admins can set any role.

Request Headers:

Authorization: Bearer <access_token>\nContent-Type: application/json\n

Request Body:

{\n  \"email\": \"newuser@example.com\",\n  \"password\": \"TempPass123\",\n  \"name\": \"New User\",\n  \"phone\": \"+1234567890\",\n  \"role\": \"MAP_ADMIN\",\n  \"status\": \"ACTIVE\",\n  \"expiresAt\": \"2026-12-31T23:59:59Z\",\n  \"expireDays\": 365\n}\n

Field Details:

Field Type Required Description email string Yes Unique email address password string Yes Minimum 8 characters (admin creation has relaxed policy) name string No Full name phone string No Phone number role UserRole No Default: USER status UserStatus No Default: ACTIVE expiresAt ISO 8601 No Expiration timestamp (for TEMP users) expireDays number No Days until expiration

Response (201 Created):

{\n  \"id\": \"clx0987654321\",\n  \"email\": \"newuser@example.com\",\n  \"name\": \"New User\",\n  \"phone\": \"+1234567890\",\n  \"role\": \"MAP_ADMIN\",\n  \"status\": \"ACTIVE\",\n  \"permissions\": null,\n  \"createdVia\": \"web\",\n  \"expiresAt\": \"2026-12-31T23:59:59.000Z\",\n  \"expireDays\": 365,\n  \"lastLoginAt\": null,\n  \"emailVerified\": false,\n  \"createdAt\": \"2026-02-11T12:00:00.000Z\",\n  \"updatedAt\": \"2026-02-11T12:00:00.000Z\"\n}\n

Error Responses:

"},{"location":"v2/backend/modules/users/#put-apiusersid","title":"PUT /api/users/:id","text":"

Update user. Admins can update any user and change role/status. Regular users can update their own profile (except role/status).

Request Headers:

Authorization: Bearer <access_token>\nContent-Type: application/json\n

Path Parameters:

Request Body (Partial Update):

{\n  \"name\": \"Updated Name\",\n  \"phone\": \"+0987654321\",\n  \"email\": \"newemail@example.com\",\n  \"password\": \"NewPass123\",\n  \"role\": \"INFLUENCE_ADMIN\",\n  \"status\": \"SUSPENDED\"\n}\n

All fields are optional (partial updates supported).

Response (200 OK):

{\n  \"id\": \"clx1234567890\",\n  \"email\": \"newemail@example.com\",\n  \"name\": \"Updated Name\",\n  \"phone\": \"+0987654321\",\n  \"role\": \"INFLUENCE_ADMIN\",\n  \"status\": \"SUSPENDED\",\n  ...\n}\n

Error Responses:

Permission Logic:

const isAdmin = ADMIN_ROLES.includes(req.user!.role);\nconst isSelf = req.user!.id === id;\n\nif (!isAdmin && !isSelf) {\n  return res.status(403).json({ error: { message: 'Insufficient permissions', code: 'FORBIDDEN' } });\n}\n\n// Non-admins cannot change role or status\nif (!isAdmin) {\n  delete req.body.role;\n  delete req.body.status;\n}\n

Password Handling:

if (data.password) {\n  updateData.password = await bcrypt.hash(data.password, 12);\n}\n
"},{"location":"v2/backend/modules/users/#delete-apiusersid","title":"DELETE /api/users/:id","text":"

Delete user (admin only). Cascades to related records (refresh tokens, created campaigns, etc.).

Request Headers:

Authorization: Bearer <access_token>\n

Path Parameters:

Example Request:

curl -X DELETE \\\n  -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/users/clx1234567890\"\n

Response (204 No Content):

No response body.

Error Responses:

Cascading Deletes:

Deleting a user automatically deletes: - Refresh tokens - Created campaigns (if createdByUserId relation) - Created locations (if createdByUserId relation) - Campaign emails - Responses - Shift signups

"},{"location":"v2/backend/modules/users/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/users/#usersservicefindallfilters","title":"usersService.findAll(filters)","text":"

Purpose: Paginated user listing with search and filters.

Parameters:

interface ListUsersInput {\n  page: number;         // Default: 1\n  limit: number;        // Default: 20, max: 100\n  search?: string;      // Search email or name\n  role?: UserRole;      // Filter by role\n  status?: UserStatus;  // Filter by status\n}\n

Returns:

{\n  users: User[];\n  pagination: {\n    page: number;\n    limit: number;\n    total: number;\n    totalPages: number;\n  };\n}\n

Implementation:

const { page, limit, search, role, status } = filters;\nconst skip = (page - 1) * limit;\n\nconst where: Prisma.UserWhereInput = {};\n\nif (search) {\n  where.OR = [\n    { email: { contains: search, mode: 'insensitive' } },\n    { name: { contains: search, mode: 'insensitive' } },\n  ];\n}\n\nif (role) where.role = role;\nif (status) where.status = status;\n\nconst [users, total] = await Promise.all([\n  prisma.user.findMany({\n    where,\n    select: userSelect,\n    skip,\n    take: limit,\n    orderBy: { createdAt: 'desc' },\n  }),\n  prisma.user.count({ where }),\n]);\n\nreturn {\n  users,\n  pagination: {\n    page,\n    limit,\n    total,\n    totalPages: Math.ceil(total / limit),\n  },\n};\n
"},{"location":"v2/backend/modules/users/#usersservicefindbyidid","title":"usersService.findById(id)","text":"

Purpose: Get single user by ID.

Returns: User object or throws 404 error.

Security: Password excluded via select (never returned in API responses).

"},{"location":"v2/backend/modules/users/#usersservicecreatedata","title":"usersService.create(data)","text":"

Purpose: Create new user with hashed password.

Flow:

  1. Check if email already exists (409 if duplicate)
  2. Hash password with bcrypt (12 salt rounds)
  3. Create user in database
  4. Return user (password excluded)

Expiration Handling:

const user = await prisma.user.create({\n  data: {\n    ...data,\n    password: hashedPassword,\n    expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined,\n  },\n  select: userSelect,\n});\n
"},{"location":"v2/backend/modules/users/#usersserviceupdateid-data","title":"usersService.update(id, data)","text":"

Purpose: Update existing user (partial updates supported).

Validation:

Email Change:

if (data.email && data.email !== existing.email) {\n  const emailTaken = await prisma.user.findUnique({ where: { email: data.email } });\n  if (emailTaken) {\n    throw new AppError(409, 'Email already in use', 'EMAIL_EXISTS');\n  }\n}\n
"},{"location":"v2/backend/modules/users/#usersservicedeleteid","title":"usersService.delete(id)","text":"

Purpose: Delete user and cascade to related records.

Error Handling:

const existing = await prisma.user.findUnique({ where: { id } });\nif (!existing) {\n  throw new AppError(404, 'User not found', 'USER_NOT_FOUND');\n}\n\nawait prisma.user.delete({ where: { id } });\n
"},{"location":"v2/backend/modules/users/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/users/#admin-list-users-with-filters","title":"Admin: List Users with Filters","text":"
import { api } from '@/lib/api';\n\nconst fetchUsers = async (page = 1, search = '', role = null, status = null) => {\n  const params = new URLSearchParams({\n    page: page.toString(),\n    limit: '20',\n  });\n\n  if (search) params.append('search', search);\n  if (role) params.append('role', role);\n  if (status) params.append('status', status);\n\n  const { data } = await api.get(`/api/users?${params}`);\n  return data;\n};\n\n// Usage\nconst result = await fetchUsers(1, 'john', 'USER', 'ACTIVE');\nconsole.log(`Total users: ${result.pagination.total}`);\nconsole.log(`Users on page 1:`, result.users);\n
"},{"location":"v2/backend/modules/users/#admin-create-user","title":"Admin: Create User","text":"
import { api } from '@/lib/api';\n\nconst createUser = async (userData) => {\n  const { data } = await api.post('/api/users', {\n    email: userData.email,\n    password: userData.password,\n    name: userData.name,\n    phone: userData.phone,\n    role: userData.role || 'USER',\n    status: userData.status || 'ACTIVE',\n  });\n\n  return data;\n};\n\n// Usage\nconst newUser = await createUser({\n  email: 'volunteer@example.com',\n  password: 'TempPass123',\n  name: 'Jane Volunteer',\n  role: 'USER',\n});\n\nconsole.log(`Created user: ${newUser.id}`);\n
"},{"location":"v2/backend/modules/users/#admin-update-user-role","title":"Admin: Update User Role","text":"
import { api } from '@/lib/api';\n\nconst promoteToAdmin = async (userId: string, adminRole: string) => {\n  const { data } = await api.put(`/api/users/${userId}`, {\n    role: adminRole,\n  });\n\n  return data;\n};\n\n// Usage\nconst updatedUser = await promoteToAdmin('clx1234567890', 'MAP_ADMIN');\nconsole.log(`User promoted to ${updatedUser.role}`);\n
"},{"location":"v2/backend/modules/users/#user-update-own-profile","title":"User: Update Own Profile","text":"
import { api } from '@/lib/api';\n\nconst updateProfile = async (name: string, phone: string) => {\n  const { data } = await api.put(`/api/users/${currentUser.id}`, {\n    name,\n    phone,\n  });\n\n  return data;\n};\n\n// Usage (non-admin user updating self)\nconst updated = await updateProfile('Updated Name', '+1234567890');\nconsole.log('Profile updated:', updated);\n
"},{"location":"v2/backend/modules/users/#admin-suspend-user","title":"Admin: Suspend User","text":"
import { api } from '@/lib/api';\n\nconst suspendUser = async (userId: string) => {\n  const { data } = await api.put(`/api/users/${userId}`, {\n    status: 'SUSPENDED',\n  });\n\n  return data;\n};\n\n// Usage\nconst suspended = await suspendUser('clx1234567890');\nconsole.log(`User ${suspended.email} suspended`);\n
"},{"location":"v2/backend/modules/users/#admin-delete-user","title":"Admin: Delete User","text":"
import { api } from '@/lib/api';\n\nconst deleteUser = async (userId: string) => {\n  await api.delete(`/api/users/${userId}`);\n  console.log(`User ${userId} deleted`);\n};\n\n// Usage\nawait deleteUser('clx1234567890');\n
"},{"location":"v2/backend/modules/users/#frontend-integration","title":"Frontend Integration","text":"

The UsersPage component (admin/src/pages/UsersPage.tsx) provides a comprehensive UI for user management:

Features:

State Management:

const [users, setUsers] = useState<User[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });\nconst [filters, setFilters] = useState({ search: '', role: null, status: null });\n

API Integration:

const fetchUsers = async () => {\n  setLoading(true);\n  try {\n    const params = new URLSearchParams({\n      page: pagination.page.toString(),\n      limit: pagination.limit.toString(),\n    });\n\n    if (filters.search) params.append('search', filters.search);\n    if (filters.role) params.append('role', filters.role);\n    if (filters.status) params.append('status', filters.status);\n\n    const { data } = await api.get(`/api/users?${params}`);\n    setUsers(data.users);\n    setPagination(data.pagination);\n  } catch (error) {\n    message.error('Failed to fetch users');\n  } finally {\n    setLoading(false);\n  }\n};\n
"},{"location":"v2/backend/modules/users/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/users/#create-user-schema","title":"Create User Schema","text":"
export const createUserSchema = z.object({\n  email: z.string().email(),\n  password: z.string().min(8, 'Password must be at least 8 characters'),\n  name: z.string().optional(),\n  phone: z.string().optional(),\n  role: z.nativeEnum(UserRole).optional(),\n  status: z.nativeEnum(UserStatus).optional(),\n  expiresAt: z.string().datetime().optional(),\n  expireDays: z.number().int().positive().optional(),\n});\n

Note: Admin user creation has relaxed password requirements (8 chars vs. 12 for public registration).

"},{"location":"v2/backend/modules/users/#update-user-schema","title":"Update User Schema","text":"
export const updateUserSchema = z.object({\n  email: z.string().email().optional(),\n  password: z.string().min(8).optional(),\n  name: z.string().optional(),\n  phone: z.string().optional(),\n  role: z.nativeEnum(UserRole).optional(),\n  status: z.nativeEnum(UserStatus).optional(),\n  expiresAt: z.string().datetime().nullable().optional(),\n  expireDays: z.number().int().positive().nullable().optional(),\n});\n
"},{"location":"v2/backend/modules/users/#list-users-schema","title":"List Users Schema","text":"
export const listUsersSchema = z.object({\n  page: z.coerce.number().int().positive().default(1),\n  limit: z.coerce.number().int().positive().max(100).default(20),\n  search: z.string().optional(),\n  role: z.nativeEnum(UserRole).optional(),\n  status: z.nativeEnum(UserStatus).optional(),\n});\n
"},{"location":"v2/backend/modules/users/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/backend/modules/users/#password-security","title":"Password Security","text":""},{"location":"v2/backend/modules/users/#permission-model","title":"Permission Model","text":"Action SUPER_ADMIN INFLUENCE_ADMIN MAP_ADMIN USER List users \u2705 \u2705 \u2705 \u274c View any user \u2705 \u2705 \u2705 Own profile only Create user \u2705 \u2705 \u2705 \u274c Update any user \u2705 \u2705 \u2705 Own profile only Change role/status \u2705 \u2705 \u2705 \u274c Delete user \u2705 \u2705 \u2705 \u274c"},{"location":"v2/backend/modules/users/#email-uniqueness","title":"Email Uniqueness","text":""},{"location":"v2/backend/modules/users/#cascading-deletes","title":"Cascading Deletes","text":"

Deleting a user automatically deletes related records via Prisma onDelete: Cascade:

"},{"location":"v2/backend/modules/users/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/backend/services/","title":"Backend Services","text":"

Shared services provide cross-cutting functionality across the Changemaker Lite platform. These services handle external integrations, background processing, and common operations.

"},{"location":"v2/backend/services/#service-architecture","title":"Service Architecture","text":"

Services are singleton instances that provide:

"},{"location":"v2/backend/services/#core-services","title":"Core Services","text":""},{"location":"v2/backend/services/#email-services","title":"Email Services","text":"

Email Service (email.service.ts)

Email Queue Service (email-queue.service.ts)

"},{"location":"v2/backend/services/#geocoding-services","title":"Geocoding Services","text":"

Geocoding Service (geocoding.service.ts)

Geocode Queue Service (geocode-queue.service.ts)

"},{"location":"v2/backend/services/#integration-services","title":"Integration Services","text":"

Listmonk Client (listmonk.client.ts)

Listmonk Sync Service (listmonk-sync.service.ts)

Pangolin Client (pangolin.client.ts)

"},{"location":"v2/backend/services/#infrastructure-services","title":"Infrastructure Services","text":"

Docker Service (docker.service.ts)

"},{"location":"v2/backend/services/#service-list","title":"Service List","text":"Service Purpose Dependencies Email Service SMTP email delivery Nodemailer Email Queue Async email processing BullMQ, Redis Geocoding Address \u2192 coordinates Multiple providers Geocode Queue Async geocoding BullMQ, Redis Listmonk Client Newsletter API Native fetch Listmonk Sync Automated list sync Listmonk Client Pangolin Client Tunnel API Native fetch Docker Service Container ops Docker API"},{"location":"v2/backend/services/#configuration","title":"Configuration","text":"

Services are configured via environment variables in api/src/config/env.ts:

// Email\nEMAIL_TEST_MODE=true          // Use MailHog instead of SMTP\nSMTP_HOST=smtp.example.com\nSMTP_PORT=587\n\n// Geocoding\nMAPBOX_ACCESS_TOKEN=pk_...\nGOOGLE_GEOCODE_API_KEY=...\n\n// Listmonk\nLISTMONK_SYNC_ENABLED=true\nLISTMONK_API_URL=http://listmonk:9000\nLISTMONK_API_USER=api_user\nLISTMONK_API_TOKEN=secret\n\n// Pangolin\nPANGOLIN_API_URL=https://api.bnkserve.org/v1\nPANGOLIN_API_KEY=...\n
"},{"location":"v2/backend/services/#usage-patterns","title":"Usage Patterns","text":""},{"location":"v2/backend/services/#email-service","title":"Email Service","text":"
import { emailService } from '../services/email.service';\n\nawait emailService.sendEmail({\n  to: 'user@example.com',\n  subject: 'Welcome',\n  html: '<p>Welcome to our platform</p>',\n});\n
"},{"location":"v2/backend/services/#email-queue","title":"Email Queue","text":"
import { emailQueueService } from '../services/email-queue.service';\n\nawait emailQueueService.addEmailJob({\n  to: 'user@example.com',\n  subject: 'Campaign Update',\n  template: 'campaign-email',\n  variables: { campaignName: 'Save the Parks' },\n});\n
"},{"location":"v2/backend/services/#geocoding-service","title":"Geocoding Service","text":"
import { geocodingService } from '../services/geocoding.service';\n\nconst result = await geocodingService.geocode({\n  address: '123 Main St, Toronto, ON',\n  provider: 'nominatim',\n});\n\nif (result.success) {\n  console.log(result.coordinates); // { lat: 43.65, lng: -79.38 }\n}\n
"},{"location":"v2/backend/services/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/backend/utilities/","title":"Backend Utilities","text":"

Utility modules provide common functionality for spatial calculations, logging, metrics collection, and data processing across the Changemaker Lite platform.

"},{"location":"v2/backend/utilities/#utility-modules","title":"Utility Modules","text":""},{"location":"v2/backend/utilities/#spatial-utilities","title":"Spatial Utilities","text":"

spatial.ts (utils/spatial.ts)

Provides geospatial calculations and polygon operations:

Point-in-Polygon - Ray-casting algorithm for polygon containment - Supports GeoJSON polygon format - Handles holes in polygons - Used for cut assignment

import { isPointInPolygon } from '../utils/spatial';\n\nconst inside = isPointInPolygon(\n  { lat: 43.65, lng: -79.38 },\n  geoJsonPolygon\n);\n

Haversine Distance - Calculate distance between two coordinates - Returns distance in kilometers - Great-circle distance calculation

import { haversineDistance } from '../utils/spatial';\n\nconst distance = haversineDistance(\n  { lat: 43.65, lng: -79.38 },\n  { lat: 43.66, lng: -79.39 }\n);\n// Returns: 1.23 (km)\n

Bounds Calculation - Calculate bounding box for set of locations - Returns min/max lat/lng - Used for map centering

import { calculateBounds } from '../utils/spatial';\n\nconst bounds = calculateBounds(locations);\n// Returns: { minLat, maxLat, minLng, maxLng }\n

Centroid Calculation - Calculate center point of locations - Geographic mean of coordinates - Used for map initial center

import { calculateCentroid } from '../utils/spatial';\n\nconst center = calculateCentroid(locations);\n// Returns: { lat, lng }\n

GeoJSON Parsing - Parse GeoJSON geometry to coordinate arrays - Support for Polygon and MultiPolygon - Coordinate validation

"},{"location":"v2/backend/utilities/#logging-utilities","title":"Logging Utilities","text":"

logger.ts (utils/logger.ts)

Winston-based logging with multiple transports:

Log Levels - error - Error conditions - warn - Warning messages - info - Informational messages - http - HTTP request logs - debug - Debug-level messages

Usage

import logger from '../utils/logger';\n\nlogger.info('Campaign created', { campaignId: 123 });\nlogger.error('Failed to send email', { error: err.message });\nlogger.debug('Geocoding result', { lat, lng });\n

Features - JSON formatting for production - Colorized console output for development - File rotation for error logs - Separate error log file - Timestamp on all logs - Request ID tracking

"},{"location":"v2/backend/utilities/#metrics-utilities","title":"Metrics Utilities","text":"

metrics.ts (utils/metrics.ts)

Prometheus metrics collection with 12 custom cm_* metrics:

Counter Metrics - cm_api_uptime_seconds - API uptime counter - cm_canvass_visits_total - Total canvass visits - cm_campaign_emails_sent_total - Total campaign emails - cm_geocode_requests_total - Total geocode requests

Gauge Metrics - cm_canvass_sessions_active - Active canvass sessions - cm_email_queue_size - Email queue depth - cm_geocode_queue_size - Geocode queue depth - cm_external_service_health - Service health status (0/1)

Histogram Metrics - cm_geocode_duration_seconds - Geocoding request duration - http_request_duration_ms - HTTP request duration

Usage

import { metrics } from '../utils/metrics';\n\n// Increment counter\nmetrics.campaignEmailsSent.inc();\n\n// Set gauge\nmetrics.emailQueueSize.set(42);\n\n// Observe histogram\nconst end = metrics.geocodeDuration.startTimer();\nawait geocode(address);\nend();\n

HTTP Metrics

Automatic tracking of: - Request count by method, route, status - Request duration percentiles - Active requests gauge

"},{"location":"v2/backend/utilities/#path-validation","title":"Path Validation","text":"

path-validator.ts (utils/path-validator.ts)

Security utilities for path validation:

Features - Null byte detection - Path traversal prevention (../ patterns) - Encoded traversal detection (%2e%2e) - Path normalization

import { validatePath } from '../utils/path-validator';\n\nconst safe = validatePath(userInput);\nif (!safe) {\n  throw new Error('Invalid path');\n}\n
"},{"location":"v2/backend/utilities/#html-sanitization","title":"HTML Sanitization","text":"

sanitize.ts (utils/sanitize.ts)

XSS prevention utilities:

import { escapeHtml } from '../utils/sanitize';\n\nconst safe = escapeHtml(userInput);\n// Escapes: < > & \" ' to HTML entities\n
"},{"location":"v2/backend/utilities/#utility-functions-summary","title":"Utility Functions Summary","text":"Utility Function Purpose Spatial isPointInPolygon() Check if point is inside polygon haversineDistance() Calculate distance between points calculateBounds() Calculate bounding box calculateCentroid() Calculate center point parseGeoJSON() Parse GeoJSON to coordinates Logging logger.info() Log informational message logger.error() Log error message logger.debug() Log debug message Metrics metrics.*.inc() Increment counter metrics.*.set() Set gauge value metrics.*.startTimer() Start histogram timer Security validatePath() Validate file path safety escapeHtml() Sanitize HTML content"},{"location":"v2/backend/utilities/#configuration","title":"Configuration","text":"

Utilities are configured via environment variables:

# Logging\nLOG_LEVEL=info              # Minimum log level\nNODE_ENV=production         # Environment mode\n\n# Metrics\nMETRICS_ENABLED=true        # Enable Prometheus metrics\n
"},{"location":"v2/backend/utilities/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/contributing/","title":"Contributing to Changemaker Lite","text":"

Thank you for your interest in contributing to Changemaker Lite! This guide will help you get started with contributing code, documentation, bug reports, and feature requests.

"},{"location":"v2/contributing/#welcome","title":"Welcome!","text":"

Changemaker Lite is an open-source political campaign platform built by volunteers for organizers. We welcome contributions from developers, designers, writers, and community organizers of all experience levels.

Our mission: Provide free, self-hosted tools for grassroots political campaigns to compete with well-funded opponents.

"},{"location":"v2/contributing/#ways-to-contribute","title":"Ways to Contribute","text":""},{"location":"v2/contributing/#1-code-contributions","title":"1. Code Contributions","text":"

Help build new features or fix bugs in:

\u2192 Development Setup Guide

"},{"location":"v2/contributing/#2-documentation","title":"2. Documentation","text":"

Improve guides, tutorials, and API documentation:

\u2192 Documentation Guide

"},{"location":"v2/contributing/#3-bug-reports","title":"3. Bug Reports","text":"

Found a bug? Help us fix it:

\u2192 Report a Bug

"},{"location":"v2/contributing/#4-feature-requests","title":"4. Feature Requests","text":"

Suggest new features or enhancements:

\u2192 Request a Feature

"},{"location":"v2/contributing/#5-testing","title":"5. Testing","text":"

Help test new features and releases:

"},{"location":"v2/contributing/#6-community-support","title":"6. Community Support","text":"

Help other users:

"},{"location":"v2/contributing/#7-design","title":"7. Design","text":"

Improve user experience:

"},{"location":"v2/contributing/#code-of-conduct","title":"Code of Conduct","text":"

Changemaker Lite is committed to providing a welcoming and inclusive environment for all contributors.

Our values:

\u2192 Full Code of Conduct

Unacceptable behavior:

Enforcement: Violations will result in warnings, temporary bans, or permanent bans depending on severity.

Reporting: Email conduct@cmlite.org to report violations confidentially.

"},{"location":"v2/contributing/#getting-started","title":"Getting Started","text":""},{"location":"v2/contributing/#prerequisites","title":"Prerequisites","text":"

Before contributing code, ensure you have:

"},{"location":"v2/contributing/#quick-start","title":"Quick Start","text":"
  1. Fork the repository on GitHub
  2. Clone your fork locally
  3. Set up development environment (guide)
  4. Find an issue to work on
  5. Create a branch for your changes
  6. Make your changes with tests
  7. Submit a pull request (guide)
"},{"location":"v2/contributing/#finding-issues-to-work-on","title":"Finding Issues to Work On","text":"

Good first issues: Look for issues tagged good-first-issue in GitHub Issues.

Help wanted: Issues tagged help-wanted need contributors.

By skill level: - beginner - Simple fixes, documentation - intermediate - Feature enhancements, refactoring - advanced - Architecture changes, performance optimization

By area: - backend - API, database, services - frontend - React components, UI/UX - infrastructure - Docker, Nginx, deployment - documentation - Guides, tutorials, API docs

\u2192 Browse Issues

"},{"location":"v2/contributing/#contribution-workflow","title":"Contribution Workflow","text":""},{"location":"v2/contributing/#1-claim-an-issue","title":"1. Claim an Issue","text":"

Before starting work:

  1. Comment on the issue: \"I'd like to work on this\"
  2. Wait for assignment: Maintainer will assign you
  3. Ask questions: Clarify requirements before coding

Avoid Duplicate Work

Always check if someone is already assigned before starting work.

"},{"location":"v2/contributing/#2-create-a-branch","title":"2. Create a Branch","text":"
# Update main branch\ngit checkout main\ngit pull upstream main\n\n# Create feature branch\ngit checkout -b feature/campaign-export\n\n# Or for bug fixes\ngit checkout -b fix/geocoding-error\n

Branch naming: - feature/description - New features - fix/description - Bug fixes - docs/description - Documentation - refactor/description - Code refactoring - test/description - Test additions

"},{"location":"v2/contributing/#3-make-changes","title":"3. Make Changes","text":"

Follow our coding standards:

// Good: Type-safe function with comments\n/**\n * Geocodes an address using the specified provider.\n * Falls back to next provider if the first fails.\n *\n * @param address - Full address string\n * @param provider - Geocoding provider (default: nominatim)\n * @returns Promise resolving to { lat, lng, quality }\n */\nasync function geocodeAddress(\n  address: string,\n  provider: GeocodingProvider = 'nominatim'\n): Promise<GeocodingResult> {\n  // Implementation\n}\n
"},{"location":"v2/contributing/#4-test-your-changes","title":"4. Test Your Changes","text":"
# Backend tests\ncd api && npm test\n\n# Frontend tests\ncd admin && npm test\n\n# Type checking\ncd api && npx tsc --noEmit\ncd admin && npx tsc --noEmit\n\n# Linting\ncd api && npm run lint\ncd admin && npm run lint\n\n# Integration tests\ndocker compose up -d\n./scripts/test-integration.sh\n
"},{"location":"v2/contributing/#5-commit-your-changes","title":"5. Commit Your Changes","text":"

Commit message format (Conventional Commits):

type(scope): short description\n\nLonger description (optional)\n\nFixes #123\n

Types: - feat - New feature - fix - Bug fix - docs - Documentation - style - Formatting, whitespace - refactor - Code restructuring - test - Test additions - chore - Build, tooling

Examples:

feat(campaigns): add campaign export to CSV\n\nAdds a new export button to the campaigns page that downloads\nall campaigns as a CSV file.\n\nFixes #456\n\n---\n\nfix(geocoding): handle null responses from Nominatim\n\nPrevents crash when Nominatim returns empty result for\ninvalid addresses.\n\nFixes #789\n\n---\n\ndocs(api): document campaign endpoints\n\nAdds comprehensive API documentation for all campaign endpoints\nincluding request/response examples.\n

"},{"location":"v2/contributing/#6-push-and-create-pull-request","title":"6. Push and Create Pull Request","text":"
# Push to your fork\ngit push origin feature/campaign-export\n\n# Create pull request on GitHub\n# Fill out the PR template\n

\u2192 Pull Request Guidelines

"},{"location":"v2/contributing/#7-code-review","title":"7. Code Review","text":"

After submitting your PR:

  1. Automated checks run (lint, tests, build)
  2. Maintainer review provides feedback
  3. Address feedback with new commits
  4. Request re-review after changes
  5. Merge after approval

Be patient: Reviews may take 1-3 business days. If no response after 5 days, politely ping the maintainer.

"},{"location":"v2/contributing/#development-guidelines","title":"Development Guidelines","text":""},{"location":"v2/contributing/#code-style","title":"Code Style","text":"

TypeScript:

// Use interfaces for object shapes\ninterface Campaign {\n  id: string;\n  title: string;\n  slug: string;\n  active: boolean;\n}\n\n// Use types for unions/aliases\ntype SupportLevel = 'STRONG_SUPPORT' | 'SUPPORT' | 'UNDECIDED' | 'OPPOSED' | 'STRONG_OPPOSED';\n\n// Prefer async/await over promises\nasync function getCampaigns(): Promise<Campaign[]> {\n  const campaigns = await prisma.campaign.findMany();\n  return campaigns;\n}\n

React:

// Use functional components\nconst CampaignsPage: React.FC = () => {\n  const [campaigns, setCampaigns] = useState<Campaign[]>([]);\n\n  useEffect(() => {\n    fetchCampaigns();\n  }, []);\n\n  return <Table dataSource={campaigns} />;\n};\n\n// Extract reusable components\nconst CampaignCard: React.FC<{ campaign: Campaign }> = ({ campaign }) => {\n  return <Card title={campaign.title} />;\n};\n

Prisma:

// Use type-safe queries\nconst campaigns = await prisma.campaign.findMany({\n  where: { active: true },\n  include: { createdBy: true },\n  orderBy: { createdAt: 'desc' }\n});\n\n// Use transactions for multi-step operations\nawait prisma.$transaction(async (tx) => {\n  await tx.campaign.update({ where: { id }, data: { active: false } });\n  await tx.campaignEmail.updateMany({ where: { campaignId: id }, data: { status: 'CANCELLED' } });\n});\n

"},{"location":"v2/contributing/#testing-guidelines","title":"Testing Guidelines","text":"

Unit tests:

// api/src/modules/campaigns/campaigns.service.test.ts\ndescribe('CampaignService', () => {\n  it('should create campaign with valid data', async () => {\n    const campaign = await campaignService.create({\n      title: 'Test Campaign',\n      slug: 'test-campaign',\n      createdByUserId: 'user-id'\n    });\n\n    expect(campaign).toHaveProperty('id');\n    expect(campaign.title).toBe('Test Campaign');\n  });\n\n  it('should throw error for duplicate slug', async () => {\n    await expect(\n      campaignService.create({ title: 'Test', slug: 'existing', createdByUserId: 'user-id' })\n    ).rejects.toThrow('Slug already exists');\n  });\n});\n

Integration tests:

// api/tests/campaigns.integration.test.ts\ndescribe('Campaigns API', () => {\n  it('GET /api/influence/campaigns returns campaigns', async () => {\n    const response = await request(app)\n      .get('/api/influence/campaigns')\n      .set('Authorization', `Bearer ${adminToken}`);\n\n    expect(response.status).toBe(200);\n    expect(response.body.success).toBe(true);\n    expect(Array.isArray(response.body.data)).toBe(true);\n  });\n});\n

"},{"location":"v2/contributing/#communication-channels","title":"Communication Channels","text":""},{"location":"v2/contributing/#github","title":"GitHub","text":""},{"location":"v2/contributing/#email","title":"Email","text":""},{"location":"v2/contributing/#community-calls","title":"Community Calls","text":"

\u2192 Join Calls

"},{"location":"v2/contributing/#recognition","title":"Recognition","text":"

We appreciate all contributors! Your name will be:

"},{"location":"v2/contributing/#hall-of-fame","title":"Hall of Fame","text":"

Top Contributors (all time):

  1. @contributor1 - 234 commits
  2. @contributor2 - 189 commits
  3. @contributor3 - 156 commits

\u2192 Full Contributors List

"},{"location":"v2/contributing/#license","title":"License","text":"

By contributing to Changemaker Lite, you agree that your contributions will be licensed under the MIT License.

This means: - Your code can be used by anyone - Attribution is required (copyright notice) - No warranty is provided

See LICENSE for full terms.

"},{"location":"v2/contributing/#questions","title":"Questions?","text":""},{"location":"v2/contributing/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/contributing/#next-steps","title":"Next Steps","text":"

Ready to contribute?

  1. Read the Code of Conduct - Understand community standards
  2. Set up your environment - Install dependencies
  3. Find an issue - Pick something to work on
  4. Submit your first PR - Make your contribution

Thank you for contributing to Changemaker Lite! Together, we're building tools for democratic change.

"},{"location":"v2/contributing/code-of-conduct/","title":"Code of Conduct","text":""},{"location":"v2/contributing/code-of-conduct/#our-pledge","title":"Our Pledge","text":"

We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.

We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.

"},{"location":"v2/contributing/code-of-conduct/#our-standards","title":"Our Standards","text":""},{"location":"v2/contributing/code-of-conduct/#examples-of-positive-behavior","title":"Examples of Positive Behavior","text":"

Behavior that contributes to a positive environment includes:

"},{"location":"v2/contributing/code-of-conduct/#examples-of-unacceptable-behavior","title":"Examples of Unacceptable Behavior","text":"

Behavior that will not be tolerated includes:

"},{"location":"v2/contributing/code-of-conduct/#enforcement-responsibilities","title":"Enforcement Responsibilities","text":"

Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.

Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.

"},{"location":"v2/contributing/code-of-conduct/#scope","title":"Scope","text":"

This Code of Conduct applies within all community spaces, including but not limited to:

This Code of Conduct also applies when an individual is officially representing the community in public spaces. Examples include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event.

"},{"location":"v2/contributing/code-of-conduct/#enforcement","title":"Enforcement","text":""},{"location":"v2/contributing/code-of-conduct/#reporting-violations","title":"Reporting Violations","text":"

Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at conduct@cmlite.org.

All complaints will be reviewed and investigated promptly and fairly.

What to include in a report:

  1. Your contact information (for follow-up)
  2. Names of people involved (or pseudonyms)
  3. Description of behavior (what happened)
  4. When and where it occurred
  5. Links or screenshots (if applicable)
  6. Any witnesses
  7. Whether you've reported elsewhere (e.g., to GitHub)

Confidentiality: All community leaders are obligated to respect the privacy and security of the reporter of any incident.

"},{"location":"v2/contributing/code-of-conduct/#investigation-process","title":"Investigation Process","text":"

Upon receiving a report:

  1. Acknowledgment: We will acknowledge receipt within 24 hours
  2. Investigation: We will review the report and gather additional information
  3. Decision: Community leaders will determine appropriate action
  4. Communication: We will inform the reporter of the outcome
  5. Action: We will enforce the decision

Timeline: Most investigations complete within 7 days.

"},{"location":"v2/contributing/code-of-conduct/#enforcement-guidelines","title":"Enforcement Guidelines","text":"

Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:

"},{"location":"v2/contributing/code-of-conduct/#1-correction","title":"1. Correction","text":"

Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.

Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.

Example: - Using mildly offensive language - Being dismissive of others' contributions - Minor disruptions in discussions

"},{"location":"v2/contributing/code-of-conduct/#2-warning","title":"2. Warning","text":"

Community Impact: A violation through a single incident or series of actions.

Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.

Example: - Repeated inappropriate language after correction - Personal attacks or insults - Sustained disruption of discussions

Duration: 7-30 days, depending on severity.

"},{"location":"v2/contributing/code-of-conduct/#3-temporary-ban","title":"3. Temporary Ban","text":"

Community Impact: A serious violation of community standards, including sustained inappropriate behavior.

Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.

Example: - Harassment or discrimination - Publishing private information - Threats or intimidation - Pattern of violations after warning

Duration: 30 days to 6 months.

"},{"location":"v2/contributing/code-of-conduct/#4-permanent-ban","title":"4. Permanent Ban","text":"

Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.

Consequence: A permanent ban from any sort of public interaction within the community.

Example: - Severe harassment or threats - Doxxing or privacy violations - Repeated violations after temporary ban - Violent or discriminatory content

Duration: Permanent.

"},{"location":"v2/contributing/code-of-conduct/#appeals","title":"Appeals","text":"

If you believe an enforcement decision was made in error, you may appeal by:

  1. Emailing conduct@cmlite.org within 14 days of the decision
  2. Providing your reasoning for why the decision was incorrect
  3. Suggesting alternative resolution (if applicable)

Appeals will be reviewed by a different community leader when possible. The appeal decision is final.

Note: Appeals are not guaranteed to result in a changed decision.

"},{"location":"v2/contributing/code-of-conduct/#attribution","title":"Attribution","text":"

This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.

Community Impact Guidelines were inspired by Mozilla's code of conduct enforcement ladder.

For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.

"},{"location":"v2/contributing/code-of-conduct/#contact","title":"Contact","text":"

Enforcement Team: conduct@cmlite.org

Project Maintainers: - Lead Maintainer: [Name] (email@cmlite.org) - Community Manager: [Name] (email@cmlite.org)

Response Time: We aim to respond to all reports within 24 hours.

"},{"location":"v2/contributing/code-of-conduct/#acknowledgments","title":"Acknowledgments","text":"

We thank all contributors who help maintain a welcoming and inclusive community. Special thanks to:

"},{"location":"v2/contributing/code-of-conduct/#version-history","title":"Version History","text":"

Last updated: February 13, 2026

By participating in this community, you agree to abide by this Code of Conduct.

"},{"location":"v2/contributing/development-setup/","title":"Development Setup","text":"

This guide will help you set up a complete development environment for contributing to Changemaker Lite V2.

"},{"location":"v2/contributing/development-setup/#prerequisites","title":"Prerequisites","text":"

Before beginning, ensure you have the following installed:

"},{"location":"v2/contributing/development-setup/#required-software","title":"Required Software","text":""},{"location":"v2/contributing/development-setup/#recommended-software","title":"Recommended Software","text":""},{"location":"v2/contributing/development-setup/#system-requirements","title":"System Requirements","text":""},{"location":"v2/contributing/development-setup/#fork-and-clone","title":"Fork and Clone","text":""},{"location":"v2/contributing/development-setup/#1-fork-the-repository","title":"1. Fork the Repository","text":"
  1. Visit https://github.com/changemaker-lite/v2
  2. Click Fork button (top right)
  3. Select your GitHub account as the destination
"},{"location":"v2/contributing/development-setup/#2-clone-your-fork","title":"2. Clone Your Fork","text":"
# Clone your fork (replace YOUR-USERNAME)\ngit clone https://github.com/YOUR-USERNAME/changemaker-lite.git\ncd changemaker-lite\n\n# Add upstream remote (original repository)\ngit remote add upstream https://github.com/changemaker-lite/v2.git\n\n# Verify remotes\ngit remote -v\n# Should show:\n# origin    https://github.com/YOUR-USERNAME/changemaker-lite.git (fetch)\n# origin    https://github.com/YOUR-USERNAME/changemaker-lite.git (push)\n# upstream  https://github.com/changemaker-lite/v2.git (fetch)\n# upstream  https://github.com/changemaker-lite/v2.git (push)\n
"},{"location":"v2/contributing/development-setup/#3-checkout-v2-branch","title":"3. Checkout V2 Branch","text":"
# Switch to v2 branch\ngit checkout v2\n\n# Verify you're on v2\ngit branch\n# Should show: * v2\n
"},{"location":"v2/contributing/development-setup/#environment-setup","title":"Environment Setup","text":""},{"location":"v2/contributing/development-setup/#1-create-environment-file","title":"1. Create Environment File","text":"
# Copy example environment file\ncp .env.example .env\n\n# Edit .env with your preferred editor\nnano .env  # or: code .env (VSCode)\n
"},{"location":"v2/contributing/development-setup/#2-configure-environment-variables","title":"2. Configure Environment Variables","text":"

Minimal development configuration:

# Database\nDATABASE_URL=postgresql://changemaker:devpassword@localhost:5433/changemaker_v2?schema=public\nV2_POSTGRES_USER=changemaker\nV2_POSTGRES_PASSWORD=devpassword\nV2_POSTGRES_DB=changemaker_v2\n\n# Redis\nREDIS_URL=redis://:devpassword@localhost:6379\nREDIS_PASSWORD=devpassword\n\n# JWT Secrets (generate with: openssl rand -hex 32)\nJWT_ACCESS_SECRET=your_access_secret_here_32_chars_min\nJWT_REFRESH_SECRET=your_refresh_secret_here_32_chars_min\nENCRYPTION_KEY=your_encryption_key_here_32_chars_min\n\n# API\nAPI_PORT=4000\nMEDIA_API_PORT=4100\nNODE_ENV=development\n\n# Email (test mode - uses MailHog)\nEMAIL_TEST_MODE=true\nSMTP_HOST=localhost\nSMTP_PORT=1025\nSMTP_FROM=dev@localhost\n\n# Feature Flags\nENABLE_MEDIA_FEATURES=true\nLISTMONK_SYNC_ENABLED=false\n

Generate secrets:

# Generate JWT secrets (run 3 times for each secret)\nopenssl rand -hex 32\n\n# On Windows (PowerShell):\n[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 }))\n

Security

Use different secrets for JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, and ENCRYPTION_KEY. Never commit .env to Git!

"},{"location":"v2/contributing/development-setup/#install-dependencies","title":"Install Dependencies","text":""},{"location":"v2/contributing/development-setup/#api-dependencies","title":"API Dependencies","text":"
cd api\n\n# Install npm packages\nnpm install\n\n# Verify installation\nnpm list prisma  # Should show @prisma/client and prisma packages\n
"},{"location":"v2/contributing/development-setup/#admin-dependencies","title":"Admin Dependencies","text":"
cd ../admin\n\n# Install npm packages\nnpm install\n\n# Verify installation\nnpm list react  # Should show react 19.x.x\n
"},{"location":"v2/contributing/development-setup/#database-setup","title":"Database Setup","text":""},{"location":"v2/contributing/development-setup/#option-1-docker-recommended-for-development","title":"Option 1: Docker (Recommended for Development)","text":"

Start PostgreSQL and Redis in Docker:

cd /home/bunker-admin/changemaker.lite\n\n# Start database services\ndocker compose up -d v2-postgres redis\n\n# Wait for PostgreSQL to be ready (about 10 seconds)\nsleep 10\n\n# Verify services running\ndocker compose ps\n# Should show v2-postgres and redis as \"running\"\n

Run Prisma migrations:

cd api\n\n# Run all migrations\nnpx prisma migrate deploy\n\n# Seed initial data (admin user, settings, blocks)\nnpx prisma db seed\n\n# Verify database\nnpx prisma studio\n# Opens browser at http://localhost:5555\n

Default admin credentials (from seed): - Email: admin@example.com - Password: Admin123!

Change Default Password

Change the default admin password immediately after first login in development.

"},{"location":"v2/contributing/development-setup/#option-2-local-postgresql-advanced","title":"Option 2: Local PostgreSQL (Advanced)","text":"

If you have PostgreSQL 16 installed locally:

# Create database and user\npsql -U postgres\nCREATE USER changemaker WITH PASSWORD 'devpassword';\nCREATE DATABASE changemaker_v2 OWNER changemaker;\n\\q\n\n# Update .env DATABASE_URL\nDATABASE_URL=postgresql://changemaker:devpassword@localhost:5432/changemaker_v2?schema=public\n\n# Run migrations\ncd api\nnpx prisma migrate deploy\nnpx prisma db seed\n
"},{"location":"v2/contributing/development-setup/#running-development-servers","title":"Running Development Servers","text":""},{"location":"v2/contributing/development-setup/#method-1-docker-compose-full-stack","title":"Method 1: Docker Compose (Full Stack)","text":"

Run all services in Docker:

# Start all services\ndocker compose up -d\n\n# View logs\ndocker compose logs -f api admin\n\n# Stop services\ndocker compose down\n

Access points: - Admin: http://localhost:3000 - API: http://localhost:4000 - Media API: http://localhost:4100 - Prisma Studio: cd api && npx prisma studio - MailHog: http://localhost:8025

"},{"location":"v2/contributing/development-setup/#method-2-local-development-hot-reload","title":"Method 2: Local Development (Hot Reload)","text":"

Run services locally for faster development:

Terminal 1 - API:

cd api\nnpm run dev\n\n# Runs on http://localhost:4000\n# Hot reload enabled (nodemon)\n

Terminal 2 - Admin:

cd admin\nnpm run dev\n\n# Runs on http://localhost:3000\n# Hot reload enabled (Vite HMR)\n

Terminal 3 - Media API (optional):

cd api\nnpm run dev:media\n\n# Runs on http://localhost:4100\n# Hot reload enabled (nodemon)\n

Terminal 4 - Database (Docker):

# Keep PostgreSQL and Redis running\ndocker compose up -d v2-postgres redis\n

Recommended Workflow

Use Method 2 (local) for frontend/backend development (faster hot reload). Use Method 1 (Docker) for testing full stack integration.

"},{"location":"v2/contributing/development-setup/#vscode-setup","title":"VSCode Setup","text":""},{"location":"v2/contributing/development-setup/#recommended-extensions","title":"Recommended Extensions","text":"

Install these VSCode extensions for better development experience:

{\n  \"recommendations\": [\n    \"dbaeumer.vscode-eslint\",           // ESLint\n    \"esbenp.prettier-vscode\",           // Prettier\n    \"prisma.prisma\",                    // Prisma syntax\n    \"bradlc.vscode-tailwindcss\",        // Tailwind (if using)\n    \"ms-azuretools.vscode-docker\",      // Docker\n    \"rangav.vscode-thunder-client\",     // API testing\n    \"editorconfig.editorconfig\",        // EditorConfig\n    \"streetsidesoftware.code-spell-checker\" // Spell check\n  ]\n}\n
"},{"location":"v2/contributing/development-setup/#workspace-settings","title":"Workspace Settings","text":"

Create .vscode/settings.json:

{\n  \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n  \"editor.formatOnSave\": true,\n  \"editor.codeActionsOnSave\": {\n    \"source.fixAll.eslint\": true\n  },\n  \"typescript.tsdk\": \"node_modules/typescript/lib\",\n  \"typescript.enablePromptUseWorkspaceTsdk\": true,\n  \"files.exclude\": {\n    \"**/.git\": true,\n    \"**/.DS_Store\": true,\n    \"**/node_modules\": true,\n    \"**/dist\": true\n  }\n}\n
"},{"location":"v2/contributing/development-setup/#debug-configuration","title":"Debug Configuration","text":"

Create .vscode/launch.json for debugging:

{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"name\": \"API (Node)\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"runtimeExecutable\": \"npm\",\n      \"runtimeArgs\": [\"run\", \"dev\"],\n      \"cwd\": \"${workspaceFolder}/api\",\n      \"console\": \"integratedTerminal\",\n      \"internalConsoleOptions\": \"neverOpen\",\n      \"skipFiles\": [\"<node_internals>/**\"]\n    },\n    {\n      \"name\": \"Admin (Chrome)\",\n      \"type\": \"chrome\",\n      \"request\": \"launch\",\n      \"url\": \"http://localhost:3000\",\n      \"webRoot\": \"${workspaceFolder}/admin/src\",\n      \"sourceMapPathOverrides\": {\n        \"webpack:///src/*\": \"${webRoot}/*\"\n      }\n    }\n  ]\n}\n
"},{"location":"v2/contributing/development-setup/#making-changes","title":"Making Changes","text":""},{"location":"v2/contributing/development-setup/#1-create-feature-branch","title":"1. Create Feature Branch","text":"
# Fetch latest changes\ngit fetch upstream\ngit checkout v2\ngit merge upstream/v2\n\n# Create feature branch\ngit checkout -b feature/your-feature-name\n\n# Or for bug fix\ngit checkout -b fix/bug-description\n
"},{"location":"v2/contributing/development-setup/#2-code-style","title":"2. Code Style","text":"

Follow project conventions:

Run linter:

cd api && npm run lint        # Backend\ncd admin && npm run lint      # Frontend\n

Auto-fix:

cd api && npm run lint:fix    # Backend\ncd admin && npm run lint:fix  # Frontend\n

Format code:

cd api && npm run format      # Backend\ncd admin && npm run format    # Frontend\n

Type check:

cd api && npx tsc --noEmit    # Backend\ncd admin && npx tsc --noEmit  # Frontend\n

"},{"location":"v2/contributing/development-setup/#3-run-tests","title":"3. Run Tests","text":"
# API unit tests\ncd api && npm test\n\n# API integration tests\ncd api && npm run test:integration\n\n# Frontend tests\ncd admin && npm test\n\n# End-to-end tests\nnpm run test:e2e\n
"},{"location":"v2/contributing/development-setup/#4-test-your-changes","title":"4. Test Your Changes","text":"

Manual testing checklist:

Integration testing:

# Start full stack\ndocker compose up -d\n\n# Run integration tests\n./scripts/test-integration.sh\n\n# Check logs for errors\ndocker compose logs -f\n

"},{"location":"v2/contributing/development-setup/#staying-synced-with-upstream","title":"Staying Synced with Upstream","text":"

Regularly sync your fork with the upstream repository:

# Fetch upstream changes\ngit fetch upstream\n\n# Merge into your local v2 branch\ngit checkout v2\ngit merge upstream/v2\n\n# Push to your fork\ngit push origin v2\n\n# Rebase your feature branch (optional, cleaner history)\ngit checkout feature/your-feature-name\ngit rebase v2\n

Rebase vs Merge

Use git rebase v2 for cleaner commit history. Use git merge v2 if you're unsure about rebasing.

"},{"location":"v2/contributing/development-setup/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/contributing/development-setup/#port-conflicts","title":"Port Conflicts","text":"

Error: Port 3000 already in use

Solution:

# Find process using port\nlsof -i :3000  # macOS/Linux\nnetstat -ano | findstr :3000  # Windows\n\n# Kill process or change port\n# Edit .env: ADMIN_PORT=3001\n

"},{"location":"v2/contributing/development-setup/#database-connection-errors","title":"Database Connection Errors","text":"

Error: Can't reach database server at localhost:5433

Solution:

# Check if PostgreSQL is running\ndocker compose ps v2-postgres\n\n# Restart PostgreSQL\ndocker compose restart v2-postgres\n\n# Check logs\ndocker compose logs v2-postgres\n\n# Verify DATABASE_URL in .env\n

"},{"location":"v2/contributing/development-setup/#migration-errors","title":"Migration Errors","text":"

Error: Migration failed

Solution:

# Reset database (WARNING: deletes all data)\ncd api\nnpx prisma migrate reset\n\n# Or manually drop and recreate\ndocker compose exec v2-postgres psql -U changemaker -d postgres -c \"DROP DATABASE changemaker_v2;\"\ndocker compose exec v2-postgres psql -U changemaker -d postgres -c \"CREATE DATABASE changemaker_v2 OWNER changemaker;\"\nnpx prisma migrate deploy\nnpx prisma db seed\n

"},{"location":"v2/contributing/development-setup/#dependency-installation-errors","title":"Dependency Installation Errors","text":"

Error: npm install fails

Solution:

# Clear npm cache\nnpm cache clean --force\n\n# Remove node_modules and package-lock.json\nrm -rf node_modules package-lock.json\n\n# Reinstall\nnpm install\n\n# If still fails, try older Node version\nnvm install 20.11.0\nnvm use 20.11.0\nnpm install\n

"},{"location":"v2/contributing/development-setup/#docker-issues","title":"Docker Issues","text":"

Error: Docker daemon not running

Solution: - macOS/Windows: Start Docker Desktop - Linux: sudo systemctl start docker

Error: Permission denied (Docker)

Solution (Linux):

# Add user to docker group\nsudo usermod -aG docker $USER\n\n# Log out and back in, or:\nnewgrp docker\n

"},{"location":"v2/contributing/development-setup/#next-steps","title":"Next Steps","text":"

Now that your environment is set up:

  1. Find an issue to work on
  2. Review code style guidelines
  3. Create your first PR
  4. Join the community
"},{"location":"v2/contributing/development-setup/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/contributing/development-setup/#getting-help","title":"Getting Help","text":"

Stuck on setup? Ask for help:

Happy coding! \ud83d\ude80

"},{"location":"v2/contributing/pull-requests/","title":"Pull Request Guidelines","text":"

This guide covers the complete pull request (PR) process for contributing code to Changemaker Lite V2, from creation to merge.

"},{"location":"v2/contributing/pull-requests/#before-submitting-a-pr","title":"Before Submitting a PR","text":""},{"location":"v2/contributing/pull-requests/#1-create-or-find-an-issue","title":"1. Create or Find an Issue","text":"

For features: - Search existing issues first - If none exists, create a feature request - Wait for maintainer approval before implementing - Discuss implementation approach in the issue

For bugs: - Search existing bug reports first - If none exists, create a bug report - Include reproduction steps and expected vs actual behavior - Verify bug still exists on latest v2 branch

Avoid Wasted Effort

Always create an issue and get approval before spending time on a large feature. Maintainers may have alternative approaches or priorities.

"},{"location":"v2/contributing/pull-requests/#2-test-your-changes","title":"2. Test Your Changes","text":"

Run all checks locally before submitting:

# Type checking\ncd api && npx tsc --noEmit\ncd admin && npx tsc --noEmit\n\n# Linting\ncd api && npm run lint\ncd admin && npm run lint\n\n# Unit tests\ncd api && npm test\ncd admin && npm test\n\n# Integration tests (if applicable)\ncd api && npm run test:integration\n\n# Build\ncd api && npm run build\ncd admin && npm run build\n

All checks must pass before submitting PR.

"},{"location":"v2/contributing/pull-requests/#3-update-documentation","title":"3. Update Documentation","text":"

If your changes affect:

Documentation is Required

PRs with new features will not be merged without corresponding documentation updates.

"},{"location":"v2/contributing/pull-requests/#pr-title-format","title":"PR Title Format","text":"

Use Conventional Commits format:

type(scope): short description\n
"},{"location":"v2/contributing/pull-requests/#types","title":"Types","text":"Type When to Use feat New feature for users fix Bug fix docs Documentation changes style Code formatting (no behavior change) refactor Code restructuring (no behavior change) perf Performance improvements test Test additions or fixes chore Build process, tooling, dependencies ci CI/CD configuration revert Reverting a previous commit"},{"location":"v2/contributing/pull-requests/#scopes","title":"Scopes","text":"

Common scopes by area:

Backend: - api - General API changes - auth - Authentication/authorization - campaigns - Campaign module - locations - Location module - shifts - Shift module - canvass - Canvassing module - email - Email sending - database - Database schema/migrations

Frontend: - admin - Admin pages - public - Public pages - volunteer - Volunteer portal - components - React components - store - Zustand stores - ui - UI/UX changes

Infrastructure: - docker - Docker/Docker Compose - nginx - Nginx configuration - monitoring - Prometheus/Grafana - deployment - Deployment scripts/config

"},{"location":"v2/contributing/pull-requests/#examples","title":"Examples","text":"

Good titles: - \u2705 feat(campaigns): add campaign export to CSV - \u2705 fix(geocoding): handle null responses from Nominatim - \u2705 docs(api): document campaign endpoints - \u2705 refactor(auth): extract JWT middleware to separate file - \u2705 perf(locations): add database index on postalCode

Bad titles: - \u274c Update campaigns.tsx (too vague) - \u274c Bug fix (no scope or description) - \u274c WIP: New feature (don't submit WIP PRs) - \u274c Fixed everything (not descriptive)

"},{"location":"v2/contributing/pull-requests/#pr-description-template","title":"PR Description Template","text":"

Use this template for your PR description:

## What\n\n[Clear description of what this PR does]\n\n## Why\n\n[Why this change is needed, link to issue]\n\n## How\n\n[Brief explanation of implementation approach]\n\n## Testing\n\n[How to test these changes]\n\n## Screenshots\n\n[For UI changes, include before/after screenshots]\n\n## Checklist\n\n- [ ] Tests added/updated\n- [ ] Documentation updated\n- [ ] No console errors\n- [ ] All CI checks pass\n- [ ] Follows code style guidelines\n\nFixes #[issue-number]\n
"},{"location":"v2/contributing/pull-requests/#example-pr-description","title":"Example PR Description","text":"
## What\n\nAdds a CSV export button to the campaigns page that allows admins to download all campaigns with their metadata.\n\n## Why\n\nUsers need to export campaign data for reporting and analysis in external tools like Excel.\n\nFixes #456\n\n## How\n\n- Added export button to CampaignsPage header\n- Created `/api/influence/campaigns/export` endpoint\n- Implemented CSV generation using `csv-stringify` library\n- Added SUPER_ADMIN/INFLUENCE_ADMIN role check\n\n## Testing\n\n1. Login as admin user\n2. Navigate to Campaigns page (/app/influence/campaigns)\n3. Click \"Export CSV\" button in page header\n4. Verify CSV file downloads with correct data\n5. Open CSV in Excel/Google Sheets to verify formatting\n\n## Screenshots\n\n![Export button in campaigns page](https://user-images.githubusercontent.com/export-button.png)\n\n## Checklist\n\n- [x] Tests added (export endpoint integration test)\n- [x] Documentation updated (API reference, admin guide)\n- [x] No console errors\n- [x] All CI checks pass\n- [x] Follows code style guidelines\n\nFixes #456\n
"},{"location":"v2/contributing/pull-requests/#creating-the-pr","title":"Creating the PR","text":""},{"location":"v2/contributing/pull-requests/#1-push-your-branch","title":"1. Push Your Branch","text":"
# Ensure your branch is up to date\ngit fetch upstream\ngit rebase upstream/v2  # or: git merge upstream/v2\n\n# Push to your fork\ngit push origin feature/your-feature-name\n\n# If you rebased, force push (with care!)\ngit push --force-with-lease origin feature/your-feature-name\n
"},{"location":"v2/contributing/pull-requests/#2-open-pr-on-github","title":"2. Open PR on GitHub","text":"
  1. Go to your fork on GitHub
  2. Click \"Pull requests\" tab
  3. Click \"New pull request\"
  4. Base repository: changemaker-lite/v2 base: v2
  5. Head repository: YOUR-USERNAME/changemaker-lite compare: feature/your-feature-name
  6. Click \"Create pull request\"
  7. Fill out the PR template (see above)
  8. Click \"Create pull request\"
"},{"location":"v2/contributing/pull-requests/#3-request-reviewers","title":"3. Request Reviewers","text":""},{"location":"v2/contributing/pull-requests/#code-review-process","title":"Code Review Process","text":""},{"location":"v2/contributing/pull-requests/#automated-checks","title":"Automated Checks","text":"

After submitting, CI/CD runs these checks:

  1. Lint: ESLint rules
  2. Type Check: TypeScript compilation
  3. Tests: Unit + integration tests
  4. Build: Production build
  5. Security: Dependency vulnerability scan

Status badges appear on your PR:

Fix failing checks before requesting review.

"},{"location":"v2/contributing/pull-requests/#maintainer-review","title":"Maintainer Review","text":"

A maintainer will review your code and provide feedback:

Review categories:

  1. Code quality:
  2. Follows code style guidelines
  3. No unnecessary complexity
  4. Proper error handling
  5. No security vulnerabilities

  6. Functionality:

  7. Solves the problem correctly
  8. Edge cases handled
  9. No regressions

  10. Tests:

  11. Adequate test coverage (>80%)
  12. Tests are meaningful
  13. Tests pass consistently

  14. Documentation:

  15. Code comments for complex logic
  16. API documentation updated
  17. User guide updated (if needed)
"},{"location":"v2/contributing/pull-requests/#review-outcomes","title":"Review Outcomes","text":"

Approved \u2705: - Maintainer approves PR - Ready to merge (after squash)

Request Changes \ud83d\udd04: - Maintainer requests modifications - Address feedback and push new commits - Re-request review after changes

Comment \ud83d\udcac: - Feedback without blocking merge - Optional to address

"},{"location":"v2/contributing/pull-requests/#addressing-feedback","title":"Addressing Feedback","text":""},{"location":"v2/contributing/pull-requests/#1-read-feedback-carefully","title":"1. Read Feedback Carefully","text":""},{"location":"v2/contributing/pull-requests/#2-make-changes","title":"2. Make Changes","text":"
# Make requested changes\n# Edit files...\n\n# Commit changes\ngit add .\ngit commit -m \"refactor: address review feedback\n\n- Extracted duplicate logic into helper function\n- Added error handling for edge case\n- Updated tests to cover new scenario\"\n\n# Push to same branch\ngit push origin feature/your-feature-name\n

Commits are added to existing PR automatically.

"},{"location":"v2/contributing/pull-requests/#3-respond-to-comments","title":"3. Respond to Comments","text":""},{"location":"v2/contributing/pull-requests/#4-re-request-review","title":"4. Re-Request Review","text":"

After addressing all feedback:

  1. Click \"Reviewers\" section
  2. Click circular arrow next to reviewer's name
  3. Or comment @reviewer Ready for re-review
"},{"location":"v2/contributing/pull-requests/#common-review-feedback","title":"Common Review Feedback","text":""},{"location":"v2/contributing/pull-requests/#code-quality-issues","title":"Code Quality Issues","text":"

Issue: Large function with too many responsibilities

Feedback:

This function is doing too much. Can you extract the geocoding logic into a separate function?

Fix:

// Before\nasync function createLocation(data) {\n  // 50 lines of validation, geocoding, database insert...\n}\n\n// After\nasync function createLocation(data) {\n  const validated = validateLocationData(data);\n  const geocoded = await geocodeAddress(validated.address);\n  return insertLocation({ ...validated, ...geocoded });\n}\n

Issue: Magic numbers or strings

Feedback:

What does 30 represent here? Use a named constant.

Fix:

// Before\nif (visits.length >= 30) { }\n\n// After\nconst VISIT_RATE_LIMIT = 30;\nif (visits.length >= VISIT_RATE_LIMIT) { }\n

Issue: Missing error handling

Feedback:

What happens if the API call fails? Add error handling.

Fix:

// Before\nconst reps = await fetch(url).then(r => r.json());\n\n// After\ntry {\n  const response = await fetch(url);\n  if (!response.ok) {\n    throw new Error(`API returned ${response.status}`);\n  }\n  const reps = await response.json();\n} catch (error) {\n  logger.error('Failed to fetch representatives', error);\n  throw new Error('Unable to lookup representatives');\n}\n

"},{"location":"v2/contributing/pull-requests/#test-coverage-issues","title":"Test Coverage Issues","text":"

Issue: Missing test for edge case

Feedback:

Add a test for when postal code is invalid.

Fix:

it('should return 400 for invalid postal code', async () => {\n  const response = await request(app)\n    .post('/api/influence/representatives/lookup')\n    .send({ postalCode: 'INVALID' });\n\n  expect(response.status).toBe(400);\n  expect(response.body.success).toBe(false);\n});\n

"},{"location":"v2/contributing/pull-requests/#documentation-issues","title":"Documentation Issues","text":"

Issue: Missing API documentation

Feedback:

Add this endpoint to the API reference docs.

Fix: Update docs/v2/api-reference/campaigns.md with new endpoint.

"},{"location":"v2/contributing/pull-requests/#performance-issues","title":"Performance Issues","text":"

Issue: N+1 query problem

Feedback:

This causes N+1 queries. Use Prisma include to join.

Fix:

// Before (N+1)\nconst campaigns = await prisma.campaign.findMany();\nfor (const campaign of campaigns) {\n  campaign.createdBy = await prisma.user.findUnique({ where: { id: campaign.createdByUserId } });\n}\n\n// After (single query)\nconst campaigns = await prisma.campaign.findMany({\n  include: { createdBy: true }\n});\n

"},{"location":"v2/contributing/pull-requests/#merge-process","title":"Merge Process","text":""},{"location":"v2/contributing/pull-requests/#squash-and-merge","title":"Squash and Merge","text":"

Changemaker Lite uses squash and merge for all PRs:

  1. Maintainer clicks \"Squash and merge\"
  2. All commits in PR are squashed into one commit
  3. Commit message = PR title + description summary
  4. Merged to v2 branch

Why squash? - Clean linear history - Easier to revert if needed - No messy \"WIP\" or \"fix typo\" commits

"},{"location":"v2/contributing/pull-requests/#after-merge","title":"After Merge","text":"

Once your PR is merged:

  1. Celebrate! \ud83c\udf89 You've contributed to Changemaker Lite
  2. Update your fork:
    git checkout v2\ngit pull upstream v2\ngit push origin v2\n
  3. Delete feature branch (optional):
    git branch -d feature/your-feature-name\ngit push origin --delete feature/your-feature-name\n
  4. Update issue: GitHub auto-closes issue with Fixes #N
  5. Check release notes: Your contribution will be mentioned in next release
"},{"location":"v2/contributing/pull-requests/#pr-checklist","title":"PR Checklist","text":"

Use this before submitting:

"},{"location":"v2/contributing/pull-requests/#pre-submission","title":"Pre-Submission","text":""},{"location":"v2/contributing/pull-requests/#code-quality","title":"Code Quality","text":""},{"location":"v2/contributing/pull-requests/#tests","title":"Tests","text":""},{"location":"v2/contributing/pull-requests/#documentation","title":"Documentation","text":""},{"location":"v2/contributing/pull-requests/#ui-if-applicable","title":"UI (if applicable)","text":""},{"location":"v2/contributing/pull-requests/#final-checks","title":"Final Checks","text":""},{"location":"v2/contributing/pull-requests/#troubleshooting-prs","title":"Troubleshooting PRs","text":""},{"location":"v2/contributing/pull-requests/#ci-checks-failing","title":"CI Checks Failing","text":"

Lint failures:

cd api && npm run lint:fix\ncd admin && npm run lint:fix\ngit add . && git commit -m \"chore: fix lint errors\" && git push\n

Type errors:

cd api && npx tsc --noEmit  # Shows errors\n# Fix type errors in code\ngit add . && git commit -m \"fix: resolve type errors\" && git push\n

Test failures:

cd api && npm test  # Run locally to see errors\n# Fix failing tests\ngit add . && git commit -m \"test: fix failing tests\" && git push\n

"},{"location":"v2/contributing/pull-requests/#merge-conflicts","title":"Merge Conflicts","text":"

Resolving conflicts:

# Fetch latest upstream\ngit fetch upstream\n\n# Rebase onto v2\ngit rebase upstream/v2\n\n# If conflicts, resolve them\n# Edit conflicted files, then:\ngit add .\ngit rebase --continue\n\n# Force push (since history changed)\ngit push --force-with-lease origin feature/your-feature-name\n

"},{"location":"v2/contributing/pull-requests/#pr-not-getting-reviewed","title":"PR Not Getting Reviewed","text":"

If no review after 5 business days:

  1. Check CI: Ensure all checks pass
  2. Ping maintainer: Comment \"@changemaker-lite/maintainers Friendly ping for review\"
  3. Join Discord: Ask in #contributors channel
  4. Email: dev@cmlite.org for urgent PRs

Reasons for delays: - Maintainers busy with other priorities - PR too large (break into smaller PRs) - Missing context (add more details to description) - Waiting on related PRs to merge first

"},{"location":"v2/contributing/pull-requests/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/contributing/pull-requests/#questions","title":"Questions?","text":"

Thank you for contributing! Every PR helps make Changemaker Lite better. \ud83d\ude80

"},{"location":"v2/contributing/roadmap/","title":"Changemaker Lite V2 Roadmap","text":"

This roadmap outlines the development journey of Changemaker Lite V2, including completed phases, current work, and future plans.

"},{"location":"v2/contributing/roadmap/#overview","title":"Overview","text":"

V2 is a complete rebuild of Changemaker Lite, transitioning from two separate Express apps to a unified modern TypeScript stack. The rebuild began in January 2025 and Phase 14 completed in February 2026.

Current Status: \u2705 Phase 1-14 Complete | \ud83d\udea7 Phase 15 In Progress

"},{"location":"v2/contributing/roadmap/#completed-phases-1-14","title":"Completed Phases (1-14)","text":""},{"location":"v2/contributing/roadmap/#phase-1-foundation-complete","title":"Phase 1: Foundation \u2705 COMPLETE","text":"

Timeline: January 2025

Deliverables: - Initialized api/ with TypeScript, Express, Prisma - Created comprehensive Prisma schema (30+ models) - Set up environment configuration (Zod validation) - Implemented middleware (error handling, validation, rate limiting) - Built utility modules (logger, metrics) - Initialized admin/ with Vite + React + Ant Design - Created Docker Compose orchestration - Wrote .env.example with 100+ variables - Backed up V1 to docker-compose.v1.yml

Key Achievements: - Clean-room architecture established - Type-safe foundation with TypeScript - Scalable project structure

"},{"location":"v2/contributing/roadmap/#phase-2-auth-user-management-complete","title":"Phase 2: Auth + User Management \u2705 COMPLETE","text":"

Timeline: January 2025

Deliverables: - Express Request type augmentation - Zod auth schemas - JWT auth service (login, register, refresh, logout) - Auth middleware (JWT verification) - RBAC middleware (role-based access) - User CRUD service + routes - Integration tested (Postman)

Key Achievements: - JWT refresh token rotation (atomic transaction) - 5 user roles (SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP) - Secure bcrypt password hashing - User enumeration prevention (401 for invalid credentials)

"},{"location":"v2/contributing/roadmap/#phase-3-admin-gui-foundation-complete","title":"Phase 3: Admin GUI Foundation \u2705 COMPLETE","text":"

Timeline: January 2025

Deliverables: - Zustand auth store with token management - Login page with form validation - Protected route wrapper - AppLayout with sidebar navigation - UsersPage with CRUD operations - Axios client with 401 refresh interceptor (callback pattern)

Key Achievements: - Automatic token refresh (seamless UX) - Role-based sidebar navigation - Responsive Ant Design components

"},{"location":"v2/contributing/roadmap/#phase-4-influence-campaigns-complete","title":"Phase 4: Influence \u2014 Campaigns \u2705 COMPLETE","text":"

Timeline: January 2025

Deliverables: - Campaign Zod schemas - Campaign service (CRUD, slug generation, toggle highlighting) - Campaign admin routes - CampaignsPage (table, filters, CRUD modals) - Feature flag integration

Key Achievements: - Unique slug generation - Highlighted campaign toggle - Response wall enable/disable per campaign

"},{"location":"v2/contributing/roadmap/#phase-5-influence-representatives-postal-codes-complete","title":"Phase 5: Influence \u2014 Representatives + Postal Codes \u2705 COMPLETE","text":"

Timeline: January 2025

Deliverables: - Postal code validation schemas (Canadian format) - Postal code cache service (Prisma) - Represent API client (typed, rate-limited 55/min) - Representative service (cache-first lookup, fire-and-forget writes) - Representative admin routes (list, stats, detail, delete) - RepresentativesPage (lookup, stats cards, table, detail modal)

Key Achievements: - Redis cache (60min TTL, ~20ms lookup) - In-memory rate limiter (Represent API limit) - Cache stats dashboard (total, by level, by party)

"},{"location":"v2/contributing/roadmap/#phase-6-influence-email-sending-complete","title":"Phase 6: Influence \u2014 Email Sending \u2705 COMPLETE","text":"

Timeline: January 2025

Deliverables: - BullMQ email queue setup - Email worker (SMTP via nodemailer) - Campaign email service (compose, queue, track) - Campaign email routes (send, track mailto, list, stats) - Email queue admin routes (stats, pause, resume, clean) - EmailQueuePage (monitoring, controls) - CampaignEmailsDrawer (stats + list from CampaignsPage)

Key Achievements: - Async email processing (BullMQ) - Email test mode (MailHog) - Rate limiting (30 req/hour per IP) - Job retry with exponential backoff

"},{"location":"v2/contributing/roadmap/#phase-7-influence-response-wall-public-campaign-view-complete","title":"Phase 7: Influence \u2014 Response Wall + Public Campaign View \u2705 COMPLETE","text":"

Timeline: January-February 2025

Deliverables: - Response service (submit, moderate, verify) - Response routes (3 routers: campaign-public, response-public, admin) - Email verification (HTML templates, verify/report endpoints) - ResponsesPage (filters, approve/reject/delete, detail drawer) - ResponseWallPage (sort, filter, submit modal, upvote) - Upvoting system (IP + user dedup, optimistic UI) - CampaignPage (postal code lookup, email sending) - CampaignsListPage (hero, featured, grid) - PublicLayout (dark theme for public pages)

Key Achievements: - Moderation workflow (PENDING \u2192 APPROVED/REJECTED) - Upvote deduplication (IP address + user ID) - Public campaign discovery

"},{"location":"v2/contributing/roadmap/#phase-8-map-locations-complete","title":"Phase 8: Map \u2014 Locations \u2705 COMPLETE","text":"

Timeline: February 2025

Deliverables: - Multi-provider geocoding service (Nominatim, ArcGIS, Photon, Mapbox, Google, OpenCage) - Location service (CRUD, geocoding, stats, bulk operations) - Location routes (admin + public) - MapSettings service + routes (singleton config) - LocationsPage (table, stats, CRUD, geocode button, CSV import/export) - MapSettingsPage (center/zoom, walk sheet config) - Public MapPage (Leaflet, circle markers, color-coded, multi-unit grouping, cut overlays, geolocate, fullscreen) - MapLegend component - MapControls (click-to-add, move, geolocate, fullscreen) - CutDrawingMode (polygon drawing with close detection) - CutOverlays + CutOverlayControls

Key Achievements: - 6 geocoding providers with automatic fallback - Geocoding quality tracking (provider, timestamp, quality score) - CSV import with flexible column mapping - Admin map enhancements (click-to-add, drag-to-move) - Point-in-polygon spatial queries (ray-casting algorithm)

"},{"location":"v2/contributing/roadmap/#phase-9-map-shifts-complete","title":"Phase 9: Map \u2014 Shifts \u2705 COMPLETE","text":"

Timeline: February 2025

Deliverables: - Shift service (CRUD, signup management) - Shift routes (admin + public) - ShiftsPage (CRUD, signups drawer, email all signups) - Public ShiftsPage (calendar view, signup cards, signup modal) - Temp user creation (30-day expiry) - Confirmation emails

Key Achievements: - Cut assignment (link shift to territory) - Signup status tracking (PENDING, CONFIRMED, CANCELLED, COMPLETED, NO_SHOW) - Public signup flow with temp user auto-creation - Email all shift signups (broadcast feature)

"},{"location":"v2/contributing/roadmap/#phase-10-walk-sheets-qr-codes-complete","title":"Phase 10: Walk Sheets & QR Codes \u2705 COMPLETE","text":"

Timeline: February 2025

Deliverables: - QR code generation endpoint (GET /api/qr, public, no auth) - WalkSheetPage (printable form with QR codes, browser print) - CutExportPage (printable location report with stats + table) - Sidebar navigation + route wiring

Key Achievements: - QR codes encode location data (address, coordinates, notes) - Print-optimized CSS (page breaks, hide buttons) - Cut-specific walk sheets (filter by cut)

"},{"location":"v2/contributing/roadmap/#phase-11-listmonk-integration-complete","title":"Phase 11: Listmonk Integration \u2705 COMPLETE","text":"

Timeline: February 2025

Deliverables: - Listmonk API client (typed HTTP, basic auth, native fetch) - Sync service (campaign participants, locations, users \u2192 subscriber lists) - Admin routes (status, stats, sync triggers, test connection, reinitialize) - ListmonkPage (status dashboard, sync buttons, list stats) - Opt-in sync flag (LISTMONK_SYNC_ENABLED)

Key Achievements: - Newsletter integration (advocacy campaigns \u2192 subscriber lists) - Automatic list creation/sync - Proton Mail SMTP configuration (listmonk-init auto-configures)

"},{"location":"v2/contributing/roadmap/#phase-12-landing-page-builder-complete","title":"Phase 12: Landing Page Builder \u2705 COMPLETE","text":"

Timeline: February 2025

Deliverables: - Landing page service (CRUD, slug generation, MkDocs export) - Page block service (seed blocks, CRUD, library API) - GrapesJS editor integration (custom blocks, Ctrl+S save, error boundary) - LandingPagesPage (table, search, settings modal) - PageEditorPage (full-screen GrapesJS, desktop-only, forwardRef) - Public LandingPage renderer (/p/:slug) - MkDocs export (Jinja2 Material override template, themed + standalone modes) - DocsPage (management, status cards, export table)

Key Achievements: - Visual page builder (drag-and-drop) - Custom block library (Hero, Features, CTA, Testimonials, etc.) - MkDocs integration (static site generation) - Jinja2 template export for Material theme

"},{"location":"v2/contributing/roadmap/#phase-13-volunteer-canvassing-system-complete","title":"Phase 13: Volunteer Canvassing System \u2705 COMPLETE","text":"

Timeline: February 2025

Deliverables: - Prisma models (CanvassSession, CanvassVisit, TrackingSession, TrackPoint) - Canvass API (volunteer routes: start/end session, record visits, walking route) - Canvass API (admin routes: dashboard stats, activity feed, cut progress, leaderboard) - Walking route algorithm (nearest-neighbor with haversine distance) - GPS tracking routes (volunteer + admin) - Abandoned session cleanup (startup + hourly, ACTIVE > 12h \u2192 ABANDONED) - Old tracking data cleanup (30-day retention, daily) - Stale tracking session cleanup (no data for 2h, hourly) - VolunteerLayout (top-nav, dark theme, mobile hamburger) - VolunteerMapPage (full-screen Leaflet, GPS, markers, route, bottom sheet visit recording) - VolunteerShiftsPage (assigned shifts, view only) - MyActivityPage (visit history, outcome breakdown) - MyRoutesPage (past session routes) - CanvassDashboardPage (stats, activity feed, cut progress, leaderboard) - ShiftsPage cutId dropdown (link shifts to cuts) - Role-aware login redirect (ADMIN_ROLES \u2192 /app, USER/TEMP \u2192 /volunteer)

Key Achievements: - Complete field canvassing workflow - Real-time GPS tracking with trail visualization - Optimized walking routes (nearest-neighbor algorithm) - Visit outcome tracking (8 outcomes: CONTACT_MADE, NOT_HOME, REFUSED, etc.) - Volunteer leaderboard (by visits, filterable by period) - Rate limiting (30 visits/min per IP)

"},{"location":"v2/contributing/roadmap/#phase-14-monitoring-devops-complete","title":"Phase 14: Monitoring + DevOps \u2705 COMPLETE","text":"

Timeline: February 2026

Pangolin Tunnel: - Pangolin Integration API client (typescript) - Admin pangolin routes (status, config, sites, resources, setup, sync, delete) - PangolinPage (setup wizard + resource dashboard) - Newt container in docker-compose.yml - Env vars (PANGOLIN_API_URL, API_KEY, ORG_ID, SITE_ID, ENDPOINT, NEWT_ID, NEWT_SECRET) - Retired Cloudflare scripts \u2192 scripts/legacy/

Prometheus Metrics: - 12 domain-specific cm_* metrics (emails, auth, canvass, services, etc.) - Instrumented modules (email-queue, auth, campaigns, responses, canvass, shifts, services) - HTTP request metrics (duration, count, errors)

Monitoring Configs: - Prometheus V2 API scrape job (removed V1 influence-app) - Alert rules (rewritten for V2 metric names) - Alertmanager Gotify webhook (commented, ready to enable) - Grafana dashboards (3 dashboards: system-health, application-overview, api-performance)

Docker Healthchecks: - 7 services with healthchecks (API, admin, nginx, NocoDB, n8n, Gitea, Listmonk)

Backup: - scripts/backup.sh (V2 PostgreSQL + Listmonk + uploads archive) - Manifest with timestamps, sizes, SHA256 checksums - Configurable retention (default 30 days) - Optional S3 upload (--s3 flag)

Key Achievements: - Self-hosted tunnel alternative (Pangolin replaces Cloudflare) - Comprehensive observability (Prometheus + Grafana) - Production-ready monitoring stack - Automated backup procedures

"},{"location":"v2/contributing/roadmap/#current-phase-15","title":"Current Phase (15)","text":""},{"location":"v2/contributing/roadmap/#phase-15-testing-polish-in-progress","title":"Phase 15: Testing + Polish \ud83d\udea7 IN PROGRESS","text":"

Timeline: February-March 2026

Goals: - Comprehensive testing (unit, integration, E2E) - Performance optimization - Security hardening - Documentation polish - Bug fixes

Planned Deliverables:

Testing: - [ ] API integration tests (Jest/Vitest) - Auth flow tests (login, refresh, logout) - Campaign CRUD tests - Location CRUD + geocoding tests - Canvass workflow tests - [ ] Admin E2E tests (Playwright/Cypress) - Login flow - Campaign creation flow - Location management flow - Canvass session flow - [ ] Test coverage reports (>80% target) - [ ] Load testing (k6 or Artillery) - API endpoint stress tests - Database query performance - Email queue throughput

Performance: - [ ] Database query optimization - Review Prisma queries for N+1 issues - Add missing indexes - Optimize spatial queries - [ ] Frontend bundle size reduction - Code splitting - Lazy loading - Tree shaking optimization - [ ] Redis cache tuning - Cache hit rate analysis - TTL optimization - Memory usage monitoring - [ ] Image optimization - WebP conversion - Lazy loading - Responsive images

Security: - [ ] Dependency audit (npm audit, Snyk) - [ ] OWASP Top 10 review - [ ] Security headers verification - [ ] Rate limiting verification - [ ] Input validation audit - [ ] SQL injection prevention check - [ ] XSS protection verification

Documentation: - [ ] API reference completion (all endpoints documented) - [ ] User guide polish (screenshots, videos) - [ ] Developer docs review (architecture, database) - [ ] Migration guide testing (V1\u2192V2 procedure verification) - [ ] Troubleshooting guide expansion (common issues)

Bug Fixes: - [ ] Review and fix open GitHub issues - [ ] Fix reported bugs (priority: critical > high > medium > low) - [ ] Address edge cases - [ ] Improve error messages

Polish: - [ ] UI/UX refinements (spacing, alignment, colors) - [ ] Accessibility improvements (keyboard nav, screen reader) - [ ] Mobile responsiveness fixes - [ ] Loading states improvements - [ ] Error state improvements

Progress: 20% (security audit complete, NAR import complete, media upload complete)

"},{"location":"v2/contributing/roadmap/#future-roadmap-phase-16","title":"Future Roadmap (Phase 16+)","text":""},{"location":"v2/contributing/roadmap/#phase-16-multi-tenancy-planned","title":"Phase 16: Multi-Tenancy (Planned)","text":"

Goal: Support multiple organizations on single instance

Features: - [ ] Tenant isolation (database row-level security) - [ ] Subdomain routing (org1.cmlite.org, org2.cmlite.org) - [ ] Tenant-specific settings - [ ] Billing integration (optional) - [ ] Admin cross-tenant management - [ ] Tenant signup flow

Technical Challenges: - Database schema changes (add tenantId to all tables) - Prisma middleware for automatic tenant filtering - JWT token tenant claim - File upload isolation (per-tenant directories)

Timeline: 2-3 months (tentative Q2 2026)

"},{"location":"v2/contributing/roadmap/#phase-17-mobile-apps-planned","title":"Phase 17: Mobile Apps (Planned)","text":"

Goal: Native iOS and Android apps for volunteers

Features: - [ ] React Native app (iOS + Android) - [ ] Volunteer canvassing optimized for mobile - [ ] Offline mode (sync when online) - [ ] Push notifications (shift reminders, campaign updates) - [ ] Location services integration - [ ] QR code scanning (walk sheets) - [ ] Photo upload (location photos)

Technical Stack: - React Native + Expo - AsyncStorage for offline data - React Query for sync - Expo Notifications - Expo Camera

Timeline: 3-4 months (tentative Q3 2026)

"},{"location":"v2/contributing/roadmap/#phase-18-advanced-analytics-planned","title":"Phase 18: Advanced Analytics (Planned)","text":"

Goal: Campaign performance and volunteer metrics

Features: - [ ] Campaign analytics dashboard - Email open rates - Response submission trends - Geographic distribution - [ ] Volunteer analytics - Canvassing efficiency metrics - Top volunteers leaderboard - Activity heatmaps - [ ] Location analytics - Support level trends over time - Geocoding quality reports - Coverage maps - [ ] Export to BI tools (Metabase, Superset)

Technical Stack: - Prisma aggregations - Chart.js or Recharts - CSV/Excel export - Optional: Metabase integration

Timeline: 2 months (tentative Q4 2026)

"},{"location":"v2/contributing/roadmap/#phase-19-ai-integration-exploratory","title":"Phase 19: AI Integration (Exploratory)","text":"

Goal: AI-powered features for campaign optimization

Potential Features: - [ ] Campaign email drafting (GPT-4 integration) - [ ] Response sentiment analysis - [ ] Canvassing route optimization (ML algorithm) - [ ] Volunteer assignment suggestions - [ ] Predictive support level classification - [ ] Automated data quality checks

Technical Considerations: - OpenAI API integration (cost considerations) - Privacy concerns (user data in AI models) - Ethical AI usage guidelines - Opt-in for AI features

Timeline: TBD (community feedback needed)

"},{"location":"v2/contributing/roadmap/#phase-20-additional-integrations-planned","title":"Phase 20: Additional Integrations (Planned)","text":"

Goal: Connect to other campaign tools

Potential Integrations: - [ ] Social media: Facebook, Twitter, Instagram posting - [ ] SMS campaigns: Twilio integration for text banking - [ ] Phone banking: VoIP integration for call tracking - [ ] Donation tracking: ActBlue, Stripe integration - [ ] Event management: Rally, town hall scheduling - [ ] Voter files: VAN/Votebuilder import - [ ] Peer-to-peer texting: Spoke, Relay integration

Timeline: Ongoing (community-driven priorities)

"},{"location":"v2/contributing/roadmap/#feature-requests","title":"Feature Requests","text":"

Have an idea for a new feature? We'd love to hear it!

"},{"location":"v2/contributing/roadmap/#how-to-request","title":"How to Request","text":"
  1. Search existing requests: Check Discussions
  2. Create new discussion: Start a discussion
  3. Provide details:
  4. Problem: What problem does this solve?
  5. Use case: Who would use this feature?
  6. Implementation ideas: How might it work?
  7. Alternatives: What workarounds exist today?
"},{"location":"v2/contributing/roadmap/#prioritization-process","title":"Prioritization Process","text":"

Features are prioritized based on:

  1. Impact: How many users benefit?
  2. Effort: How complex to implement?
  3. Strategic fit: Aligns with mission?
  4. Community votes: Upvote discussions
  5. Funding: Sponsored development

High-priority features: - Requested by many users - Low implementation effort - Core to mission (campaign advocacy, volunteer management)

Low-priority features: - Niche use cases - High complexity - Available via integrations

"},{"location":"v2/contributing/roadmap/#community-voting","title":"Community Voting","text":"

Upvote feature requests in GitHub Discussions:

  1. Go to Ideas category
  2. Click \ud83d\udc4d on discussions you want
  3. Comment with your use case

Most-upvoted features are considered for roadmap.

"},{"location":"v2/contributing/roadmap/#contribution-opportunities","title":"Contribution Opportunities","text":"

Want to contribute to the roadmap?

"},{"location":"v2/contributing/roadmap/#code-contributions","title":"Code Contributions","text":"

\u2192 Find Issues

"},{"location":"v2/contributing/roadmap/#design-contributions","title":"Design Contributions","text":""},{"location":"v2/contributing/roadmap/#documentation-contributions","title":"Documentation Contributions","text":""},{"location":"v2/contributing/roadmap/#sponsorship","title":"Sponsorship","text":"

Support development of specific features:

\u2192 Sponsor on GitHub

"},{"location":"v2/contributing/roadmap/#release-schedule","title":"Release Schedule","text":""},{"location":"v2/contributing/roadmap/#version-numbering","title":"Version Numbering","text":"

Changemaker Lite uses Semantic Versioning:

Current version: 2.0.0-beta.1 (Phase 15 in progress)

"},{"location":"v2/contributing/roadmap/#release-cycle","title":"Release Cycle","text":"

Major releases: 6-12 months (major new features, breaking changes)

Minor releases: 1-2 months (new features, no breaking changes)

Patch releases: 1-2 weeks (bug fixes, security patches)

"},{"location":"v2/contributing/roadmap/#upcoming-releases","title":"Upcoming Releases","text":"

v2.0.0 (stable release): - Target: March 2026 - Requires: Phase 15 complete (testing, polish) - Breaking changes from beta: TBD

v2.1.0: - Target: May 2026 - Features: TBD based on community feedback

v2.2.0: - Target: July 2026 - Features: Possibly multi-tenancy (Phase 16)

"},{"location":"v2/contributing/roadmap/#long-term-vision","title":"Long-Term Vision","text":"

Mission: Provide free, self-hosted tools for grassroots political campaigns.

5-Year Vision (2026-2031):

  1. Year 1 (2026): V2 stable, 100+ organizations using Changemaker Lite
  2. Year 2 (2027): Multi-tenancy, mobile apps, 500+ organizations
  3. Year 3 (2028): Advanced analytics, AI features, 1000+ organizations
  4. Year 4 (2029): Ecosystem of integrations, international campaigns
  5. Year 5 (2030): Changemaker Lite as standard platform for grassroots advocacy

Success Metrics: - Number of organizations using platform - Number of campaigns run - Number of volunteers coordinated - Number of emails sent to representatives - Community contributions (PRs, issues, discussions)

"},{"location":"v2/contributing/roadmap/#breaking-changes-policy","title":"Breaking Changes Policy","text":""},{"location":"v2/contributing/roadmap/#commitment","title":"Commitment","text":"

We strive to minimize breaking changes in V2 minor releases. When breaking changes are necessary:

  1. Advance notice: Announced 2 releases prior (e.g., deprecation in v2.1.0, removal in v2.3.0)
  2. Migration guide: Detailed upgrade guide provided
  3. Deprecation warnings: Console warnings in code
  4. Major version bumps: Breaking changes only in major releases (v2\u2192v3)
"},{"location":"v2/contributing/roadmap/#deprecation-process","title":"Deprecation Process","text":"
  1. Deprecate: Mark feature as deprecated (console warnings)
  2. Announce: Publish deprecation notice in release notes
  3. Wait: Keep deprecated feature for 2 releases minimum
  4. Remove: Remove in next major version

Example: - v2.1.0: Deprecate /api/old-endpoint (with warnings) - v2.2.0: Still supported, warnings continue - v2.3.0: Still supported, migration guide published - v3.0.0: Removed (breaking change)

"},{"location":"v2/contributing/roadmap/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/contributing/roadmap/#feedback","title":"Feedback","text":"

Have feedback on the roadmap?

Together, we're building the future of grassroots political campaigns! \ud83d\ude80

"},{"location":"v2/database/","title":"Database Documentation","text":""},{"location":"v2/database/#overview","title":"Overview","text":"

Changemaker Lite V2 uses a dual ORM architecture with PostgreSQL 16 as the backing database:

Both ORMs share the same PostgreSQL database but maintain separate schemas and migration workflows.

"},{"location":"v2/database/#database-architecture","title":"Database Architecture","text":"

Database: PostgreSQL 16 Connection: DATABASE_URL environment variable Total Models: 33 models organized into 9 groups Migration Tools: Prisma Migrate (main API), Drizzle Kit (media API)

"},{"location":"v2/database/#key-design-patterns","title":"Key Design Patterns","text":"
  1. Audit Fields \u2014 Most models include:
  2. createdAt / updatedAt timestamps
  3. createdByUserId / updatedByUserId user references
  4. Automatic tracking via Prisma middleware

  5. Soft Deletes \u2014 Some models use status fields instead of hard deletes:

  6. User: status (ACTIVE/INACTIVE/SUSPENDED/EXPIRED)
  7. Campaign: status (DRAFT/ACTIVE/PAUSED/ARCHIVED)
  8. Shift: status (OPEN/FULL/CANCELLED)

  9. JSON Fields \u2014 Used for flexible schema:

  10. permissions (User) \u2014 granular per-app permissions
  11. offices (Representative) \u2014 array of office contact info
  12. tags (videos) \u2014 array of tag strings
  13. geojson (Cut) \u2014 GeoJSON polygon coordinates
  14. blocks (LandingPage) \u2014 GrapesJS editor output

  15. Enums \u2014 18 enums for type safety:

  16. UserRole, UserStatus, CampaignStatus, GovernmentLevel, EmailMethod, ResponseType, ResponseStatus, SupportLevel, GeocodeProvider, BuildingType, LocationHistoryAction, ShiftStatus, SignupStatus, SignupSource, CutCategory, VisitOutcome, CanvassSessionStatus, TrackPointEvent, EmailTemplateCategory, EditorMode, MkdocsExportMode

  17. Cascade Deletes \u2014 Foreign keys with onDelete: Cascade:

  18. Deleting a Campaign deletes all CampaignEmail, RepresentativeResponse, CustomRecipient, Call records
  19. Deleting a Location deletes all Address and LocationHistory records
  20. Deleting a Shift deletes all ShiftSignup records
  21. Deleting a CanvassSession deletes all CanvassVisit records

  22. Indexes \u2014 Strategic indexing for performance:

  23. All foreign keys indexed (userId, campaignId, locationId, etc.)
  24. Composite indexes for common queries (latitude+longitude, locationId+unitNumber, etc.)
  25. Unique constraints (email, slug, postalCode, token, etc.)
"},{"location":"v2/database/#complete-entity-relationship-diagram","title":"Complete Entity Relationship Diagram","text":"
erDiagram\n    %% ============================================================================\n    %% AUTH & USERS\n    %% ============================================================================\n\n    User ||--o{ RefreshToken : has\n    User ||--o{ Campaign : creates\n    User ||--o{ CampaignEmail : sends\n    User ||--o{ RepresentativeResponse : submits\n    User ||--o{ ResponseUpvote : upvotes\n    User ||--o{ ShiftSignup : \"signs up for\"\n    User ||--o{ Location : creates\n    User ||--o{ Location : updates\n    User ||--o{ Address : \"creates (addresses)\"\n    User ||--o{ Address : \"updates (addresses)\"\n    User ||--o{ LocationHistory : edits\n    User ||--o{ Cut : \"creates (cuts)\"\n    User ||--o{ CanvassVisit : visits\n    User ||--o{ CanvassSession : \"has (sessions)\"\n    User ||--o{ TrackingSession : \"tracks (gps)\"\n    User ||--o{ EmailTemplate : \"creates (templates)\"\n    User ||--o{ EmailTemplate : \"updates (templates)\"\n    User ||--o{ EmailTemplateVersion : \"versions (templates)\"\n    User ||--o{ EmailTemplateTestLog : \"tests (templates)\"\n\n    User {\n        String id PK\n        String email UK \"bcrypt hashed\"\n        String password \"bcrypt\"\n        String name\n        String phone\n        UserRole role \"SUPER_ADMIN | INFLUENCE_ADMIN | MAP_ADMIN | USER | TEMP\"\n        UserStatus status \"ACTIVE | INACTIVE | SUSPENDED | EXPIRED\"\n        Json permissions \"granular per-app\"\n        UserCreatedVia createdVia \"ADMIN | PUBLIC_SHIFT_SIGNUP | STANDARD\"\n        DateTime expiresAt \"for TEMP users\"\n        Int expireDays\n        DateTime lastLoginAt\n        Boolean emailVerified\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    RefreshToken {\n        String id PK\n        String token UK \"JWT refresh token\"\n        String userId FK\n        DateTime expiresAt\n        DateTime createdAt\n    }\n\n    %% ============================================================================\n    %% INFLUENCE \u2014 CAMPAIGNS\n    %% ============================================================================\n\n    Campaign ||--o{ CampaignEmail : sends\n    Campaign ||--o{ RepresentativeResponse : receives\n    Campaign ||--o{ CustomRecipient : targets\n    Campaign ||--o{ Call : tracks\n\n    Campaign {\n        String id PK\n        String slug UK\n        String title\n        String description\n        String emailSubject\n        String emailBody\n        String callToAction\n        String coverPhoto\n        CampaignStatus status \"DRAFT | ACTIVE | PAUSED | ARCHIVED\"\n        Boolean allowSmtpEmail \"default: true\"\n        Boolean allowMailtoLink \"default: true\"\n        Boolean collectUserInfo \"default: true\"\n        Boolean showEmailCount \"default: true\"\n        Boolean showCallCount \"default: true\"\n        Boolean allowEmailEditing \"default: false\"\n        Boolean allowCustomRecipients \"default: false\"\n        Boolean showResponseWall \"default: false\"\n        Boolean highlightCampaign \"default: false\"\n        GovernmentLevel[] targetGovernmentLevels\n        String createdByUserId FK\n        String createdByUserEmail\n        String createdByUserName\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    CampaignEmail {\n        String id PK\n        String campaignId FK\n        String campaignSlug\n        String userId FK\n        String userEmail\n        String userName\n        String userPostalCode\n        String recipientEmail\n        String recipientName\n        String recipientTitle\n        GovernmentLevel recipientLevel\n        EmailMethod emailMethod \"SMTP | MAILTO\"\n        String subject\n        String message\n        CampaignEmailStatus status \"QUEUED | SENT | FAILED | CLICKED | USER_INFO_CAPTURED\"\n        String senderIp\n        DateTime sentAt\n    }\n\n    Representative {\n        String id PK\n        String postalCode IDX\n        String name\n        String email\n        String districtName\n        String electedOffice\n        String partyName\n        String representativeSetName\n        String url\n        String photoUrl\n        Json offices \"array of office contact info\"\n        DateTime cachedAt\n    }\n\n    CustomRecipient {\n        String id PK\n        String campaignId FK\n        String campaignSlug\n        String recipientName\n        String recipientEmail\n        String recipientTitle\n        String recipientOrganization\n        String notes\n        Boolean isActive\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    PostalCodeCache {\n        String id PK\n        String postalCode UK\n        String city\n        String province\n        Decimal centroidLat\n        Decimal centroidLng\n        DateTime lastUpdated\n    }\n\n    Call {\n        String id PK\n        String representativeName\n        String representativeTitle\n        String phoneNumber\n        String officeType\n        String callerName\n        String callerEmail\n        String postalCode\n        String campaignId FK\n        String campaignSlug\n        String callerIp\n        DateTime calledAt\n    }\n\n    %% ============================================================================\n    %% INFLUENCE \u2014 RESPONSE WALL\n    %% ============================================================================\n\n    RepresentativeResponse ||--o{ ResponseUpvote : gets\n\n    RepresentativeResponse {\n        String id PK\n        String campaignId FK\n        String campaignSlug\n        String representativeName\n        String representativeTitle\n        GovernmentLevel representativeLevel\n        String representativeEmail\n        ResponseType responseType \"EMAIL | LETTER | PHONE_CALL | MEETING | SOCIAL_MEDIA | OTHER\"\n        String responseText\n        String userComment\n        String screenshotUrl\n        String submittedByUserId FK\n        String submittedByName\n        String submittedByEmail\n        Boolean isAnonymous\n        ResponseStatus status \"PENDING | APPROVED | REJECTED\"\n        Boolean isVerified\n        String verificationToken\n        DateTime verificationSentAt\n        DateTime verifiedAt\n        String verifiedBy\n        Int upvoteCount\n        String submittedIp\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    ResponseUpvote {\n        String id PK\n        String responseId FK\n        String userId FK\n        String userEmail\n        String upvotedIp\n    }\n\n    EmailLog {\n        String id PK\n        String recipientEmail\n        String senderName\n        String senderEmail\n        String subject\n        String message\n        String postalCode\n        String status \"sent | failed | previewed\"\n        String senderIp\n        DateTime sentAt\n    }\n\n    EmailVerification {\n        String id PK\n        String token UK\n        String email\n        String tempCampaignData \"JSON\"\n        DateTime createdAt\n        DateTime expiresAt\n        Boolean used\n    }\n\n    %% ============================================================================\n    %% MAP \u2014 LOCATIONS\n    %% ============================================================================\n\n    Location ||--o{ Address : contains\n    Location ||--o{ LocationHistory : logs\n\n    Location {\n        String id PK\n        Decimal latitude \"required, precision: 10,8\"\n        Decimal longitude \"required, precision: 11,8\"\n        String address \"base street address, no unit\"\n        String postalCode\n        String province\n        String federalDistrict\n        Int buildingUse \"NAR BU_USE: 1=Residential, 2=Partial, 3=Non-Residential, 4=Unknown\"\n        String locGuid UK \"NAR LOC_GUID\"\n        BuildingType buildingType \"SINGLE_FAMILY | MULTI_UNIT | MIXED_USE | COMMERCIAL\"\n        Int totalUnits\n        String buildingNotes \"access codes, manager contact\"\n        Int geocodeConfidence \"0-100\"\n        GeocodeProvider geocodeProvider\n        String createdByUserId FK\n        String updatedByUserId FK\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    Address {\n        String id PK\n        String locationId FK\n        String unitNumber\n        String addrGuid UK \"NAR ADDR_GUID\"\n        String firstName\n        String lastName\n        String email\n        String phone\n        SupportLevel supportLevel \"1 | 2 | 3 | 4\"\n        Boolean sign\n        String signSize\n        String notes\n        String createdByUserId FK\n        String updatedByUserId FK\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    LocationHistory {\n        String id PK\n        String locationId FK\n        String userId FK\n        LocationHistoryAction action \"CREATED | UPDATED | GEOCODED | BULK_GEOCODED | MOVED_ON_MAP | IMPORTED_CSV | IMPORTED_NAR\"\n        String field \"which field changed\"\n        String oldValue\n        String newValue\n        Json metadata \"provider, confidence, etc\"\n        DateTime createdAt\n    }\n\n    %% ============================================================================\n    %% MAP \u2014 SHIFTS & CUTS\n    %% ============================================================================\n\n    Cut ||--o{ Shift : schedules\n    Shift ||--o{ ShiftSignup : has\n    Shift ||--o{ CanvassVisit : \"visits (shift)\"\n    Shift ||--o{ CanvassSession : \"sessions (shift)\"\n\n    Shift {\n        String id PK\n        String title\n        String description\n        DateTime date\n        String startTime \"HH:MM\"\n        String endTime \"HH:MM\"\n        String location\n        Int maxVolunteers\n        Int currentVolunteers\n        ShiftStatus status \"OPEN | FULL | CANCELLED\"\n        Boolean isPublic\n        String cutId FK\n        String createdBy\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    ShiftSignup {\n        String id PK\n        String shiftId FK\n        String shiftTitle\n        String userId FK\n        String userEmail\n        String userName\n        String userPhone\n        DateTime signupDate\n        SignupStatus status \"CONFIRMED | CANCELLED\"\n        SignupSource signupSource \"AUTHENTICATED | PUBLIC | ADMIN\"\n    }\n\n    Cut {\n        String id PK\n        String name\n        String description\n        String color\n        Decimal opacity\n        CutCategory category \"CUSTOM | WARD | NEIGHBORHOOD | DISTRICT\"\n        Boolean isPublic\n        Boolean isOfficial\n        String geojson \"GeoJSON polygon data\"\n        String bounds \"bounding box JSON\"\n        Boolean showLocations\n        Boolean exportEnabled\n        String assignedTo\n        Json filterSettings\n        DateTime lastCanvassed\n        Int completionPercentage\n        String createdByUserId FK\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    MapSettings {\n        String id PK\n        Decimal latitude\n        Decimal longitude\n        Int zoom\n        String walkSheetTitle\n        String walkSheetSubtitle\n        String walkSheetFooter\n        String qrCode1Url\n        String qrCode1Label\n        String qrCode2Url\n        String qrCode2Label\n        String qrCode3Url\n        String qrCode3Label\n        String createdBy\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    %% ============================================================================\n    %% CANVASSING\n    %% ============================================================================\n\n    Cut ||--o{ CanvassSession : \"sessions (cut)\"\n    CanvassSession ||--o{ CanvassVisit : records\n    CanvassSession ||--|| TrackingSession : tracks\n    Address ||--o{ CanvassVisit : \"visited (address)\"\n\n    CanvassSession {\n        String id PK\n        String userId FK\n        String cutId FK\n        String shiftId FK\n        CanvassSessionStatus status \"ACTIVE | COMPLETED | ABANDONED\"\n        DateTime startedAt\n        DateTime endedAt\n        Decimal startLatitude\n        Decimal startLongitude\n    }\n\n    CanvassVisit {\n        String id PK\n        String addressId FK\n        String userId FK\n        String shiftId FK\n        String sessionId FK\n        VisitOutcome outcome \"NOT_HOME | REFUSED | MOVED | ALREADY_VOTED | SPOKE_WITH | LEFT_LITERATURE | COME_BACK_LATER\"\n        SupportLevel supportLevel\n        Boolean signRequested\n        String signSize\n        String notes\n        Int durationSeconds\n        DateTime visitedAt\n    }\n\n    TrackingSession {\n        String id PK\n        String userId FK\n        String canvassSessionId UK\n        DateTime startedAt\n        DateTime endedAt\n        Boolean isActive\n        Int totalPoints\n        Float totalDistanceM\n        Decimal lastLatitude\n        Decimal lastLongitude\n        DateTime lastRecordedAt\n    }\n\n    TrackingSession ||--o{ TrackPoint : logs\n\n    TrackPoint {\n        String id PK\n        String trackingSessionId FK\n        Decimal latitude\n        Decimal longitude\n        Float accuracy\n        DateTime recordedAt\n        TrackPointEvent eventType \"LOCATION_ADDED | VISIT_RECORDED | SESSION_STARTED | SESSION_ENDED\"\n    }\n\n    %% ============================================================================\n    %% EMAIL TEMPLATES\n    %% ============================================================================\n\n    EmailTemplate ||--o{ EmailTemplateVariable : defines\n    EmailTemplate ||--o{ EmailTemplateVersion : versions\n    EmailTemplate ||--o{ EmailTemplateTestLog : tests\n\n    EmailTemplate {\n        String id PK\n        String key UK \"e.g., campaign-email\"\n        String name \"display name\"\n        String description\n        EmailTemplateCategory category \"INFLUENCE | MAP | SYSTEM\"\n        String subjectLine \"with {{VAR}} support\"\n        String htmlContent\n        String textContent\n        Boolean isSystem \"prevent deletion\"\n        Boolean isActive\n        String createdByUserId FK\n        String updatedByUserId FK\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    EmailTemplateVariable {\n        String id PK\n        String templateId FK\n        String key \"e.g., USER_NAME\"\n        String label \"e.g., User Name\"\n        String description\n        Boolean isRequired\n        Boolean isConditional \"used in {{#if}} blocks\"\n        String sampleValue\n        Int sortOrder\n    }\n\n    EmailTemplateVersion {\n        String id PK\n        String templateId FK\n        Int versionNumber \"auto-increment per template\"\n        String subjectLine\n        String htmlContent\n        String textContent\n        String changeNotes\n        String createdByUserId FK\n        DateTime createdAt\n    }\n\n    EmailTemplateTestLog {\n        String id PK\n        String templateId FK\n        String recipientEmail\n        Json testData \"sample variable values\"\n        Boolean success\n        String errorMessage\n        String messageId \"nodemailer message ID\"\n        String sentByUserId FK\n        DateTime sentAt\n    }\n\n    %% ============================================================================\n    %% LANDING PAGES\n    %% ============================================================================\n\n    LandingPage {\n        String id PK\n        String slug UK\n        String title\n        String description\n        Json blocks \"GrapesJS editor JSON\"\n        String htmlOutput\n        String cssOutput\n        EditorMode editorMode \"VISUAL | CODE\"\n        String mkdocsPath \"path in mkdocs/overrides/\"\n        String mkdocsStubPath \"path to .md stub\"\n        MkdocsExportMode mkdocsExportMode \"THEMED | STANDALONE\"\n        Boolean mkdocsHideNav\n        Boolean mkdocsHideToc\n        Boolean mkdocsSkipExport\n        Boolean published\n        String seoTitle\n        String seoDescription\n        String seoImage\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    PageBlock {\n        String id PK\n        String type \"hero | text | image | cta | features | testimonials | form\"\n        String label\n        Json schema \"block configuration schema\"\n        Json defaults \"default values\"\n        String thumbnail\n        String category\n        Int sortOrder\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    %% ============================================================================\n    %% SITE SETTINGS\n    %% ============================================================================\n\n    SiteSettings {\n        String id PK\n        String organizationName\n        String organizationShortName\n        String organizationLogoUrl\n        String organizationFaviconUrl\n        String adminColorPrimary\n        String adminColorBgBase\n        String publicColorPrimary\n        String publicColorBgBase\n        String publicColorBgContainer\n        String publicHeaderGradient\n        String footerText\n        String loginSubtitle\n        String emailFromName\n        String smtpHost\n        Int smtpPort\n        String smtpUser\n        String smtpPass\n        String smtpFromAddress\n        String smtpActiveProvider \"mailhog | production\"\n        Boolean emailTestMode\n        String testEmailRecipient\n        Boolean enableInfluence\n        Boolean enableMap\n        Boolean enableNewsletter\n        Boolean enableLandingPages\n        DateTime createdAt\n        DateTime updatedAt\n    }
"},{"location":"v2/database/#model-groups","title":"Model Groups","text":"

The database is organized into 9 logical groups:

"},{"location":"v2/database/#1-auth-users","title":"1. Auth & Users","text":"

Key Features: bcrypt passwords (12+ chars policy), role-based access control, temp user expiration, email verification

"},{"location":"v2/database/#2-influence","title":"2. Influence","text":"

Key Features: Multi-government-level targeting, response moderation workflow (PENDING \u2192 APPROVED/REJECTED), BullMQ integration for email queue, upvote deduplication

"},{"location":"v2/database/#3-map-locations","title":"3. Map \u2014 Locations","text":"

Key Features: Building vs unit architecture, multi-provider geocoding (6 providers), NAR 2025 import support, spatial indexing, GeoJSON storage

"},{"location":"v2/database/#4-canvassing","title":"4. Canvassing","text":"

Key Features: Walking route algorithm, session abandonment logic (12h timeout), distance calculation, support level tracking

"},{"location":"v2/database/#5-email-templates","title":"5. Email Templates","text":"

Key Features: Handlebars-style variable interpolation ({{VAR}}), conditional variables, system template protection, version auto-increment

"},{"location":"v2/database/#6-landing-pages","title":"6. Landing Pages","text":"

Key Features: GrapesJS JSON storage, MkDocs export modes (THEMED vs STANDALONE), SEO metadata, slug-based routing

"},{"location":"v2/database/#7-settings","title":"7. Settings","text":"

Key Features: Singleton pattern, SMTP override hierarchy (SiteSettings \u2192 .env), feature flags

"},{"location":"v2/database/#8-media-drizzle-orm","title":"8. Media (Drizzle ORM)","text":"

Key Features: Dual ORM architecture, FFprobe metadata extraction, directory type enum (9 types), job queue with GPU/CPU resource tracking

"},{"location":"v2/database/#9-sharedstandalone-models","title":"9. Shared/Standalone Models","text":""},{"location":"v2/database/#field-types-reference","title":"Field Types Reference","text":"Prisma Type PostgreSQL Type Description Example String text Variable-length text \"admin@cmlite.org\" String @db.Text text Long-form text (no char limit) Campaign descriptions Int integer 32-bit integer 42 BigInt bigint 64-bit integer (Node: number mode) File sizes Boolean boolean True/false true Decimal numeric Arbitrary precision decimal Lat/lng coordinates Decimal @db.Decimal(10, 8) numeric(10, 8) 10 digits, 8 after decimal 53.54612345 DateTime timestamp with time zone Timestamp 2025-02-11T10:30:00Z DateTime @db.Date date Date only (no time) Shift dates Json jsonb JSON data (binary storage) Arrays, objects Enum enum Enumerated type UserRole.SUPER_ADMIN"},{"location":"v2/database/#enum-definitions","title":"Enum Definitions","text":""},{"location":"v2/database/#auth-users","title":"Auth & Users","text":""},{"location":"v2/database/#influence","title":"Influence","text":""},{"location":"v2/database/#map","title":"Map","text":""},{"location":"v2/database/#canvassing","title":"Canvassing","text":""},{"location":"v2/database/#email-templates","title":"Email Templates","text":""},{"location":"v2/database/#landing-pages","title":"Landing Pages","text":""},{"location":"v2/database/#media-drizzle","title":"Media (Drizzle)","text":""},{"location":"v2/database/#index-strategy-overview","title":"Index Strategy Overview","text":""},{"location":"v2/database/#foreign-key-indexes","title":"Foreign Key Indexes","text":"

All foreign key fields are indexed for join performance: - userId, campaignId, locationId, addressId, shiftId, cutId, sessionId, templateId, trackingSessionId

"},{"location":"v2/database/#composite-indexes","title":"Composite Indexes","text":"

Strategic multi-column indexes for common query patterns: - [latitude, longitude] (Location) \u2014 spatial queries - [locationId, unitNumber] (Address) \u2014 unit lookups - [campaignId, status] (RepresentativeResponse) \u2014 filtered response lists - [isActive, lastRecordedAt] (TrackingSession) \u2014 active session cleanup - [templateId, createdAt(sort: Desc)] (EmailTemplateVersion) \u2014 version history - [directoryType, isValid, orientation] (videos) \u2014 media library filtering

"},{"location":"v2/database/#unique-constraints","title":"Unique Constraints","text":"

Enforce data integrity: - email (User) - slug (Campaign, LandingPage) - postalCode (PostalCodeCache) - token (RefreshToken, EmailVerification) - key (EmailTemplate) - [responseId, userId] (ResponseUpvote) \u2014 prevent duplicate upvotes from logged-in users - [responseId, upvotedIp] (ResponseUpvote) \u2014 prevent duplicate upvotes from same IP - [shiftId, userEmail] (ShiftSignup) \u2014 prevent duplicate shift signups - [templateId, key] (EmailTemplateVariable) \u2014 unique variable keys per template - [templateId, versionNumber] (EmailTemplateVersion) \u2014 sequential version numbers

"},{"location":"v2/database/#foreign-key-conventions","title":"Foreign Key Conventions","text":""},{"location":"v2/database/#cascade-deletes","title":"Cascade Deletes","text":"

onDelete: Cascade\n
Used when child records should be deleted with parent: - RefreshToken \u2192 User - CampaignEmail \u2192 Campaign - RepresentativeResponse \u2192 Campaign - CustomRecipient \u2192 Campaign - Call \u2192 Campaign (SetNull) - Address \u2192 Location - LocationHistory \u2192 Location - ShiftSignup \u2192 Shift - CanvassVisit \u2192 Address, CanvassSession - TrackPoint \u2192 TrackingSession - EmailTemplateVariable \u2192 EmailTemplate - EmailTemplateVersion \u2192 EmailTemplate - EmailTemplateTestLog \u2192 EmailTemplate

"},{"location":"v2/database/#set-null","title":"Set Null","text":"

onDelete: SetNull\n
Used when child records should remain but orphan the reference: - Campaign.createdByUserId \u2192 User - CampaignEmail.userId \u2192 User - RepresentativeResponse.submittedByUserId \u2192 User - Location.createdByUserId/updatedByUserId \u2192 User - Shift.cutId \u2192 Cut - CanvassSession.shiftId \u2192 Shift - TrackingSession.canvassSessionId \u2192 CanvassSession

"},{"location":"v2/database/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/database/#quick-links","title":"Quick Links","text":""},{"location":"v2/database/indexes/","title":"Index Strategy & Performance","text":""},{"location":"v2/database/indexes/#overview","title":"Overview","text":"

Changemaker Lite V2 uses strategic indexing across 33 models to optimize query performance. This document catalogs all indexes, explains their purpose, and provides query optimization guidance.

Total Indexes: 60+ (Prisma: 50+, Drizzle: 10+)

Index Types: - Unique indexes \u2014 Enforce uniqueness constraints (email, slug, token, etc.) - Foreign key indexes \u2014 Optimize JOIN operations (userId, campaignId, locationId, etc.) - Composite indexes \u2014 Multi-column indexes for complex queries - Spatial indexes \u2014 Latitude/longitude for geographic queries

"},{"location":"v2/database/indexes/#index-catalog","title":"Index Catalog","text":""},{"location":"v2/database/indexes/#auth-users","title":"Auth & Users","text":""},{"location":"v2/database/indexes/#user","title":"User","text":""},{"location":"v2/database/indexes/#refreshtoken","title":"RefreshToken","text":""},{"location":"v2/database/indexes/#influence","title":"Influence","text":""},{"location":"v2/database/indexes/#campaign","title":"Campaign","text":""},{"location":"v2/database/indexes/#representative","title":"Representative","text":""},{"location":"v2/database/indexes/#campaignemail","title":"CampaignEmail","text":""},{"location":"v2/database/indexes/#representativeresponse","title":"RepresentativeResponse","text":""},{"location":"v2/database/indexes/#responseupvote","title":"ResponseUpvote","text":""},{"location":"v2/database/indexes/#customrecipient","title":"CustomRecipient","text":""},{"location":"v2/database/indexes/#postalcodecache","title":"PostalCodeCache","text":""},{"location":"v2/database/indexes/#call","title":"Call","text":""},{"location":"v2/database/indexes/#map-locations","title":"Map \u2014 Locations","text":""},{"location":"v2/database/indexes/#location","title":"Location","text":"

Query Optimization:

-- Uses composite index for bounding box queries\nSELECT * FROM locations\nWHERE latitude BETWEEN ? AND ?\n  AND longitude BETWEEN ? AND ?;\n

"},{"location":"v2/database/indexes/#address","title":"Address","text":"

Query Optimization:

-- Uses composite index for unit-specific queries\nSELECT * FROM addresses\nWHERE location_id = ? AND unit_number = ?;\n

"},{"location":"v2/database/indexes/#locationhistory","title":"LocationHistory","text":""},{"location":"v2/database/indexes/#map-shifts-cuts","title":"Map \u2014 Shifts & Cuts","text":""},{"location":"v2/database/indexes/#shift","title":"Shift","text":""},{"location":"v2/database/indexes/#shiftsignup","title":"ShiftSignup","text":""},{"location":"v2/database/indexes/#canvassing","title":"Canvassing","text":""},{"location":"v2/database/indexes/#canvasssession","title":"CanvassSession","text":""},{"location":"v2/database/indexes/#canvassvisit","title":"CanvassVisit","text":""},{"location":"v2/database/indexes/#trackingsession","title":"TrackingSession","text":"

Query Optimization:

-- Uses composite index for abandoned session cleanup\nSELECT * FROM tracking_sessions\nWHERE is_active = true\n  AND last_recorded_at < NOW() - INTERVAL '12 hours';\n

"},{"location":"v2/database/indexes/#trackpoint","title":"TrackPoint","text":""},{"location":"v2/database/indexes/#email-templates","title":"Email Templates","text":""},{"location":"v2/database/indexes/#emailtemplate","title":"EmailTemplate","text":""},{"location":"v2/database/indexes/#emailtemplatevariable","title":"EmailTemplateVariable","text":""},{"location":"v2/database/indexes/#emailtemplateversion","title":"EmailTemplateVersion","text":"

Query Optimization:

-- Uses composite index for recent version queries\nSELECT * FROM email_template_versions\nWHERE template_id = ?\nORDER BY created_at DESC\nLIMIT 10;\n

"},{"location":"v2/database/indexes/#emailtemplatetestlog","title":"EmailTemplateTestLog","text":""},{"location":"v2/database/indexes/#landing-pages","title":"Landing Pages","text":""},{"location":"v2/database/indexes/#landingpage","title":"LandingPage","text":""},{"location":"v2/database/indexes/#media-drizzle-orm","title":"Media (Drizzle ORM)","text":""},{"location":"v2/database/indexes/#videos","title":"videos","text":"

Query Optimization:

-- Uses composite index for common video library queries\nSELECT * FROM videos\nWHERE directory_type = 'studios'\n  AND is_valid = true\n  AND orientation = 'landscape';\n

"},{"location":"v2/database/indexes/#jobs","title":"jobs","text":"

Query Optimization:

-- Uses composite index for job queue queries\nSELECT * FROM jobs\nWHERE status = 'pending'\nORDER BY priority ASC, created_at ASC\nLIMIT 10;\n

"},{"location":"v2/database/indexes/#query-optimization-patterns","title":"Query Optimization Patterns","text":""},{"location":"v2/database/indexes/#1-use-indexes-for-where-clauses","title":"1. Use Indexes for WHERE Clauses","text":"
// \u2705 Uses email unique index\nawait prisma.user.findUnique({ where: { email: 'user@example.com' } });\n\n// \u274c Full table scan (no index on name)\nawait prisma.user.findMany({ where: { name: 'John' } });\n
"},{"location":"v2/database/indexes/#2-use-composite-indexes-for-multi-column-filters","title":"2. Use Composite Indexes for Multi-Column Filters","text":"
// \u2705 Uses [latitude, longitude] composite index\nawait prisma.location.findMany({\n  where: {\n    latitude: { gte: 53.5, lte: 53.6 },\n    longitude: { gte: -113.5, lte: -113.4 },\n  },\n});\n\n// \u274c Less efficient (only uses latitude index)\nawait prisma.location.findMany({\n  where: {\n    latitude: { gte: 53.5, lte: 53.6 },\n    // longitude filter applied after index scan\n  },\n});\n
"},{"location":"v2/database/indexes/#3-use-foreign-key-indexes-for-joins","title":"3. Use Foreign Key Indexes for JOINs","text":"
// \u2705 Uses campaignId foreign key index\nawait prisma.campaign.findUnique({\n  where: { id: campaignId },\n  include: { emails: true }, // JOIN uses index\n});\n\n// \u274c N+1 query (loads emails one-by-one)\nconst campaign = await prisma.campaign.findUnique({ where: { id: campaignId } });\nconst emails = await prisma.campaignEmail.findMany({ where: { campaignId: campaign.id } });\n
"},{"location":"v2/database/indexes/#4-use-unique-indexes-for-deduplication","title":"4. Use Unique Indexes for Deduplication","text":"
// \u2705 Uses [responseId, userId] unique index\nawait prisma.responseUpvote.create({\n  data: { responseId, userId, upvotedIp },\n});\n// Throws error if user already upvoted (database-level check)\n\n// \u274c Application-level check (race condition)\nconst existing = await prisma.responseUpvote.findFirst({\n  where: { responseId, userId },\n});\nif (existing) throw new Error('Already upvoted');\nawait prisma.responseUpvote.create({ data: { responseId, userId } });\n
"},{"location":"v2/database/indexes/#5-use-temporal-indexes-for-date-filtering","title":"5. Use Temporal Indexes for Date Filtering","text":"
// \u2705 Uses createdAt index\nawait prisma.locationHistory.findMany({\n  where: {\n    createdAt: { gte: new Date('2025-01-01') },\n  },\n  orderBy: { createdAt: 'desc' },\n  take: 100,\n});\n\n// \u274c Full table scan (no index on field)\nawait prisma.locationHistory.findMany({\n  where: {\n    oldValue: { contains: 'Calgary' }, // No index\n  },\n});\n
"},{"location":"v2/database/indexes/#index-selectivity","title":"Index Selectivity","text":"

Selectivity = Percentage of unique values in indexed column. Higher selectivity = better index performance.

"},{"location":"v2/database/indexes/#high-selectivity-good","title":"High Selectivity (Good)","text":""},{"location":"v2/database/indexes/#medium-selectivity-okay","title":"Medium Selectivity (Okay)","text":""},{"location":"v2/database/indexes/#low-selectivity-poor-for-filtering-good-for-covering-index","title":"Low Selectivity (Poor for filtering, good for covering index)","text":"

Optimization: - Use low-selectivity indexes as first column in composite index only - Example: [isActive, lastRecordedAt] uses isActive to narrow search, then lastRecordedAt for ordering

"},{"location":"v2/database/indexes/#index-maintenance","title":"Index Maintenance","text":""},{"location":"v2/database/indexes/#prisma-indexes-automatic","title":"Prisma Indexes (Automatic)","text":"

Prisma migrations automatically create indexes defined in schema.prisma:

model Location {\n  latitude  Decimal\n  longitude Decimal\n\n  @@index([latitude, longitude])  // Composite index\n}\n

"},{"location":"v2/database/indexes/#drizzle-indexes-manual-in-schema","title":"Drizzle Indexes (Manual in Schema)","text":"

Drizzle indexes defined in schema.ts:

export const videos = pgTable('videos', {\n  directoryType: text('directory_type'),\n  isValid: boolean('is_valid'),\n  orientation: text('orientation'),\n}, (table) => ({\n  directoryValidOrientationIdx: index('idx_videos_directory_valid_orientation')\n    .on(table.directoryType, table.isValid, table.orientation),\n}));\n

"},{"location":"v2/database/indexes/#index-size-monitoring","title":"Index Size Monitoring","text":"
-- Check index sizes\nSELECT\n  tablename,\n  indexname,\n  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size\nFROM pg_stat_user_indexes\nWHERE schemaname = 'public'\nORDER BY pg_relation_size(indexrelid) DESC;\n
"},{"location":"v2/database/indexes/#unused-index-detection","title":"Unused Index Detection","text":"
-- Find indexes with zero scans (unused)\nSELECT\n  schemaname,\n  tablename,\n  indexname,\n  idx_scan,\n  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size\nFROM pg_stat_user_indexes\nWHERE schemaname = 'public'\n  AND idx_scan = 0\n  AND indexrelid NOT IN (\n    SELECT conindid FROM pg_constraint WHERE contype IN ('p', 'u')\n  )\nORDER BY pg_relation_size(indexrelid) DESC;\n
"},{"location":"v2/database/indexes/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/database/indexes/#index-trade-offs","title":"Index Trade-offs","text":"

Rule of Thumb: - Index all foreign keys (JOIN performance) - Index all unique constraints (data integrity) - Index columns used in WHERE clauses frequently - Avoid indexing low-selectivity columns alone - Avoid indexing large text fields (use full-text search instead)

"},{"location":"v2/database/indexes/#query-planning","title":"Query Planning","text":"

Use EXPLAIN ANALYZE to verify index usage:

EXPLAIN ANALYZE\nSELECT * FROM locations\nWHERE latitude BETWEEN 53.5 AND 53.6\n  AND longitude BETWEEN -113.5 AND -113.4;\n\n-- Output should show \"Index Scan using locations_latitude_longitude_idx\"\n

"},{"location":"v2/database/indexes/#index-bloat","title":"Index Bloat","text":"

Over time, indexes can become bloated (unused space). Monitor with:

SELECT\n  schemaname,\n  tablename,\n  indexname,\n  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,\n  idx_scan,\n  idx_tup_read,\n  idx_tup_fetch\nFROM pg_stat_user_indexes\nWHERE schemaname = 'public'\nORDER BY pg_relation_size(indexrelid) DESC;\n

Fix bloat: REINDEX INDEX index_name; (requires table lock)

"},{"location":"v2/database/indexes/#common-performance-issues","title":"Common Performance Issues","text":""},{"location":"v2/database/indexes/#issue-slow-campaign-email-stats-query","title":"Issue: Slow campaign email stats query","text":"

Query:

SELECT COUNT(*) FROM campaign_emails WHERE campaign_id = ?;\n

Solution: Already optimized (uses campaignId foreign key index)

"},{"location":"v2/database/indexes/#issue-slow-location-bounding-box-queries","title":"Issue: Slow location bounding box queries","text":"

Query:

SELECT * FROM locations WHERE latitude > ? AND latitude < ? AND longitude > ? AND longitude < ?;\n

Solution: Already optimized (uses [latitude, longitude] composite index)

"},{"location":"v2/database/indexes/#issue-slow-active-session-cleanup","title":"Issue: Slow active session cleanup","text":"

Query:

SELECT * FROM tracking_sessions WHERE is_active = true AND last_recorded_at < ?;\n

Solution: Already optimized (uses [isActive, lastRecordedAt] composite index)

"},{"location":"v2/database/indexes/#issue-slow-template-version-history","title":"Issue: Slow template version history","text":"

Query:

SELECT * FROM email_template_versions WHERE template_id = ? ORDER BY created_at DESC LIMIT 10;\n

Solution: Already optimized (uses [templateId, createdAt(sort: Desc)] composite index)

"},{"location":"v2/database/indexes/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/database/migrations/","title":"Migration Workflow","text":""},{"location":"v2/database/migrations/#overview","title":"Overview","text":"

Changemaker Lite V2 uses a dual ORM architecture with separate migration workflows:

Both ORMs share the same PostgreSQL database but maintain independent migration histories.

"},{"location":"v2/database/migrations/#prisma-migration-workflow","title":"Prisma Migration Workflow","text":""},{"location":"v2/database/migrations/#development-workflow","title":"Development Workflow","text":""},{"location":"v2/database/migrations/#1-modify-schema","title":"1. Modify Schema","text":"

Edit api/prisma/schema.prisma:

model Location {\n  id       String  @id @default(cuid())\n  address  String\n  // Add new field:\n  province String?\n  // ...\n}\n

"},{"location":"v2/database/migrations/#2-create-migration","title":"2. Create Migration","text":"
cd api\nnpx prisma migrate dev --name add_province_to_location\n

This command: - Generates SQL migration file in prisma/migrations/ - Applies migration to database - Regenerates Prisma Client - Updates _prisma_migrations table

Output:

Prisma schema loaded from prisma/schema.prisma\nDatasource \"db\": PostgreSQL database \"changemaker_v2\", schema \"public\"\n\nApplying migration `20260213120000_add_province_to_location`\n\nThe following migration(s) have been created and applied from new schema changes:\n\nmigrations/\n  \u2514\u2500 20260213120000_add_province_to_location/\n      \u2514\u2500 migration.sql\n\nYour database is now in sync with your schema.\n

"},{"location":"v2/database/migrations/#3-review-migration-sql","title":"3. Review Migration SQL","text":"
-- migrations/20260213120000_add_province_to_location/migration.sql\n-- AlterTable\nALTER TABLE \"locations\" ADD COLUMN \"province\" TEXT;\n
"},{"location":"v2/database/migrations/#4-commit-migration","title":"4. Commit Migration","text":"
git add prisma/migrations/\ngit commit -m \"Add province field to Location model\"\n
"},{"location":"v2/database/migrations/#production-workflow","title":"Production Workflow","text":""},{"location":"v2/database/migrations/#1-deploy-migration","title":"1. Deploy Migration","text":"
docker compose exec api npx prisma migrate deploy\n

This command: - Applies pending migrations from prisma/migrations/ - Does NOT create new migrations - Does NOT prompt for confirmations - Safe for production/CI pipelines

"},{"location":"v2/database/migrations/#2-verify-migration-status","title":"2. Verify Migration Status","text":"
docker compose exec api npx prisma migrate status\n

Output:

1 migration found in prisma/migrations\n\nFollowing migration have been applied:\n\n20260213120000_add_province_to_location\n\nDatabase schema is up to date!\n

"},{"location":"v2/database/migrations/#common-migration-scenarios","title":"Common Migration Scenarios","text":""},{"location":"v2/database/migrations/#add-field-nullable","title":"Add Field (Nullable)","text":"

model Location {\n  federalDistrict String?  // Add nullable field\n}\n
Migration:
ALTER TABLE \"locations\" ADD COLUMN \"federal_district\" TEXT;\n

"},{"location":"v2/database/migrations/#add-field-required-with-default","title":"Add Field (Required with Default)","text":"

model Location {\n  buildingType BuildingType @default(SINGLE_FAMILY)\n}\n
Migration:
ALTER TABLE \"locations\" ADD COLUMN \"building_type\" TEXT NOT NULL DEFAULT 'SINGLE_FAMILY';\n

"},{"location":"v2/database/migrations/#add-relation","title":"Add Relation","text":"

model Shift {\n  cutId String?\n  cut   Cut?    @relation(fields: [cutId], references: [id], onDelete: SetNull)\n}\n
Migration:
ALTER TABLE \"shifts\" ADD COLUMN \"cut_id\" TEXT;\nCREATE INDEX \"shifts_cut_id_idx\" ON \"shifts\"(\"cut_id\");\nALTER TABLE \"shifts\" ADD CONSTRAINT \"shifts_cut_id_fkey\" FOREIGN KEY (\"cut_id\") REFERENCES \"cuts\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n

"},{"location":"v2/database/migrations/#change-field-type","title":"Change Field Type","text":"

model Location {\n  geocodeConfidence Int?  // Changed from String? to Int?\n}\n
Migration (requires data migration):
-- Step 1: Add new column\nALTER TABLE \"locations\" ADD COLUMN \"geocode_confidence_new\" INTEGER;\n\n-- Step 2: Migrate data (custom logic)\nUPDATE \"locations\" SET \"geocode_confidence_new\" = CAST(\"geocode_confidence\" AS INTEGER)\nWHERE \"geocode_confidence\" ~ '^[0-9]+$';\n\n-- Step 3: Drop old column\nALTER TABLE \"locations\" DROP COLUMN \"geocode_confidence\";\n\n-- Step 4: Rename new column\nALTER TABLE \"locations\" RENAME COLUMN \"geocode_confidence_new\" TO \"geocode_confidence\";\n

"},{"location":"v2/database/migrations/#add-enum","title":"Add Enum","text":"

enum BuildingType {\n  SINGLE_FAMILY\n  MULTI_UNIT\n  MIXED_USE\n  COMMERCIAL\n}\n
Migration:
CREATE TYPE \"BuildingType\" AS ENUM ('SINGLE_FAMILY', 'MULTI_UNIT', 'MIXED_USE', 'COMMERCIAL');\n

"},{"location":"v2/database/migrations/#add-index","title":"Add Index","text":"

model Location {\n  latitude  Decimal\n  longitude Decimal\n\n  @@index([latitude, longitude])\n}\n
Migration:
CREATE INDEX \"locations_latitude_longitude_idx\" ON \"locations\"(\"latitude\", \"longitude\");\n

"},{"location":"v2/database/migrations/#migration-commands-reference","title":"Migration Commands Reference","text":"Command Description Environment npx prisma migrate dev Create + apply migration Development npx prisma migrate deploy Apply pending migrations Production/CI npx prisma migrate status Check migration status All npx prisma migrate reset Reset DB + apply all migrations Development only npx prisma db push Push schema without migrations Prototyping only npx prisma studio Open Prisma Studio (DB GUI) Development"},{"location":"v2/database/migrations/#safe-migration-practices","title":"Safe Migration Practices","text":""},{"location":"v2/database/migrations/#do","title":"\u2705 DO","text":""},{"location":"v2/database/migrations/#dont","title":"\u274c DON'T","text":""},{"location":"v2/database/migrations/#drizzle-migration-workflow","title":"Drizzle Migration Workflow","text":""},{"location":"v2/database/migrations/#development-workflow_1","title":"Development Workflow","text":""},{"location":"v2/database/migrations/#1-modify-schema_1","title":"1. Modify Schema","text":"

Edit api/src/modules/media/db/schema.ts:

export const videos = pgTable('videos', {\n  id: serial('id').primaryKey(),\n  path: text('path').notNull().unique(),\n  // Add new field:\n  description: text('description'),\n  // ...\n});\n

"},{"location":"v2/database/migrations/#2-push-schema-changes","title":"2. Push Schema Changes","text":"
cd api\nnpx drizzle-kit push\n

This command: - Generates SQL diff from schema - Applies changes directly to database - Does NOT create migration files (Drizzle push mode) - Updates database schema immediately

Output:

Reading config file '/home/bunker-admin/changemaker.lite/api/drizzle.config.ts'\nPulling schema from database...\u2713\nApplying changes...\n\n[\u2713] Applying: ALTER TABLE \"videos\" ADD COLUMN \"description\" text;\n\nSchema applied successfully!\n

"},{"location":"v2/database/migrations/#3-verify-schema","title":"3. Verify Schema","text":"

npx drizzle-kit studio\n
Opens Drizzle Studio at https://local.drizzle.studio/ for database inspection.

"},{"location":"v2/database/migrations/#production-workflow_1","title":"Production Workflow","text":"

Same as development:

docker compose exec media-api npx drizzle-kit push\n

"},{"location":"v2/database/migrations/#drizzle-vs-prisma-migrate","title":"Drizzle vs Prisma Migrate","text":"Feature Prisma Migrate Drizzle Kit Push Migration files \u2713 Generated \u2717 Not generated Migration history \u2713 Tracked in _prisma_migrations \u2717 No history table Rollback support \u2713 Via migration files \u2717 Manual only Production safety \u2713 Explicit deploy step \u26a0\ufe0f Direct push Best for Main API (schema stability) Media API (rapid iteration)

Why Drizzle for Media API? - Smaller schema (3 tables vs 30) - Faster iteration during development - Simpler deployment (no migration history to manage) - Media API is newer (less risk of breaking changes)

"},{"location":"v2/database/migrations/#drizzle-commands-reference","title":"Drizzle Commands Reference","text":"Command Description npx drizzle-kit push Push schema changes to DB npx drizzle-kit studio Open Drizzle Studio npx drizzle-kit generate Generate migrations (not used)"},{"location":"v2/database/migrations/#migration-file-structure","title":"Migration File Structure","text":""},{"location":"v2/database/migrations/#prisma-migrations","title":"Prisma Migrations","text":"
api/prisma/migrations/\n\u251c\u2500\u2500 20260211120000_initial/\n\u2502   \u2514\u2500\u2500 migration.sql\n\u251c\u2500\u2500 20260211125000_add_refresh_tokens/\n\u2502   \u2514\u2500\u2500 migration.sql\n\u251c\u2500\u2500 20260212100000_add_canvass_system/\n\u2502   \u2514\u2500\u2500 migration.sql\n\u2514\u2500\u2500 migration_lock.toml\n

File naming: YYYYMMDDHHMMSS_description/migration.sql

migration_lock.toml:

# Please do not edit this file manually\nprovider = \"postgresql\"\n

"},{"location":"v2/database/migrations/#drizzle-schema-no-migrations","title":"Drizzle Schema (No Migrations)","text":"
api/src/modules/media/db/\n\u251c\u2500\u2500 schema.ts          # Source of truth\n\u2514\u2500\u2500 drizzle.config.ts  # Drizzle config\n
"},{"location":"v2/database/migrations/#rollback-strategies","title":"Rollback Strategies","text":""},{"location":"v2/database/migrations/#prisma-rollback-manual","title":"Prisma Rollback (Manual)","text":"

Scenario: Migration 20260213120000_add_province caused issues.

Step 1: Identify last good migration

npx prisma migrate status\n

Step 2: Manually revert migration SQL

-- Reverse of migration.sql\nALTER TABLE \"locations\" DROP COLUMN \"province\";\n

Step 3: Mark migration as rolled back

DELETE FROM \"_prisma_migrations\" WHERE migration_name = '20260213120000_add_province';\n

Step 4: Remove migration file

rm -rf prisma/migrations/20260213120000_add_province/\n

Step 5: Fix schema Edit prisma/schema.prisma to remove province field.

Step 6: Create new migration

npx prisma migrate dev --name remove_province_from_location\n

"},{"location":"v2/database/migrations/#drizzle-rollback-manual","title":"Drizzle Rollback (Manual)","text":"

Step 1: Revert schema changes in schema.ts

Step 2: Push reverted schema

npx drizzle-kit push\n

Step 3: If data loss occurred, restore from backup

"},{"location":"v2/database/migrations/#common-migration-errors","title":"Common Migration Errors","text":""},{"location":"v2/database/migrations/#error-migration-failed-to-apply-cleanly","title":"Error: \"Migration failed to apply cleanly\"","text":"

Cause: Database state doesn't match expected state Solution:

npx prisma migrate resolve --applied <migration-name>  # Mark as applied\n# OR\nnpx prisma migrate resolve --rolled-back <migration-name>  # Mark as rolled back\n

"},{"location":"v2/database/migrations/#error-unique-constraint-violation","title":"Error: \"Unique constraint violation\"","text":"

Cause: Trying to add unique constraint on column with duplicate values Solution: 1. Clean up duplicate data first 2. Run migration

"},{"location":"v2/database/migrations/#error-column-cannot-be-not-null","title":"Error: \"Column cannot be NOT NULL\"","text":"

Cause: Trying to add required field to table with existing rows Solution: Use @default() or make field nullable

"},{"location":"v2/database/migrations/#error-foreign-key-constraint-failed","title":"Error: \"Foreign key constraint failed\"","text":"

Cause: Referencing non-existent records Solution: Ensure related records exist before adding FK

"},{"location":"v2/database/migrations/#database-backup-before-migration","title":"Database Backup Before Migration","text":""},{"location":"v2/database/migrations/#development","title":"Development","text":"
docker compose exec v2-postgres pg_dump -U changemaker_v2 changemaker_v2 > backup.sql\n
"},{"location":"v2/database/migrations/#production","title":"Production","text":"
# Via docker-compose\ndocker compose exec v2-postgres pg_dump -U changemaker_v2 changemaker_v2 | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz\n\n# Via backup script\n./scripts/backup.sh\n
"},{"location":"v2/database/migrations/#restore-from-backup","title":"Restore from Backup","text":"
# Stop API services\ndocker compose stop api media-api\n\n# Restore database\ndocker compose exec -T v2-postgres psql -U changemaker_v2 changemaker_v2 < backup.sql\n\n# Restart services\ndocker compose up -d api media-api\n
"},{"location":"v2/database/migrations/#cicd-integration","title":"CI/CD Integration","text":""},{"location":"v2/database/migrations/#github-actions-example","title":"GitHub Actions Example","text":"
name: Deploy V2\n\non:\n  push:\n    branches: [main]\n\njobs:\n  migrate:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-node@v3\n        with:\n          node-version: 20\n\n      - name: Install dependencies\n        run: cd api && npm ci\n\n      - name: Run Prisma migrations\n        run: cd api && npx prisma migrate deploy\n        env:\n          DATABASE_URL: ${{ secrets.DATABASE_URL }}\n\n      - name: Run Drizzle push\n        run: cd api && npx drizzle-kit push\n        env:\n          DATABASE_URL: ${{ secrets.DATABASE_URL }}\n
"},{"location":"v2/database/migrations/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/database/schema/","title":"Complete Schema Reference","text":"

This page provides a comprehensive listing of all 33 models across both Prisma and Drizzle ORMs.

"},{"location":"v2/database/schema/#models-summary","title":"Models Summary","text":"Group Model Table Name Description ORM Auth & Users User users User accounts with role-based access control Prisma RefreshToken refresh_tokens JWT refresh token storage Prisma Influence Campaign campaigns Advocacy campaigns with feature flags Prisma Representative representatives Cached representative data from Represent API Prisma CampaignEmail campaign_emails Email tracking and delivery logs Prisma RepresentativeResponse representative_responses Response wall submissions with moderation Prisma ResponseUpvote response_upvotes Upvote tracking with deduplication Prisma CustomRecipient custom_recipients Custom email targets for campaigns Prisma PostalCodeCache postal_code_cache Postal code geocoding cache Prisma EmailLog email_logs Global email audit trail Prisma EmailVerification email_verifications Email verification tokens Prisma Call calls Phone call tracking Prisma Map \u2014 Locations Location locations Building-level address data with geocoding Prisma Address addresses Unit-level data with support levels Prisma LocationHistory location_history Audit trail for location changes Prisma Map \u2014 Shifts & Cuts Shift shifts Volunteer shifts with scheduling Prisma ShiftSignup shift_signups Shift signup tracking Prisma Cut cuts GeoJSON polygon overlays for map filtering Prisma MapSettings map_settings Singleton for map configuration Prisma Canvassing CanvassSession canvass_sessions Canvassing session lifecycle Prisma CanvassVisit canvass_visits Visit recording with outcomes Prisma TrackingSession tracking_sessions GPS tracking sessions Prisma TrackPoint track_points GPS breadcrumb trail Prisma Email Templates EmailTemplate email_templates Email template master records Prisma EmailTemplateVariable email_template_variables Template variable definitions Prisma EmailTemplateVersion email_template_versions Template version history Prisma EmailTemplateTestLog email_template_test_logs Test email audit logs Prisma Landing Pages LandingPage landing_pages GrapesJS editor output with MkDocs export Prisma PageBlock page_blocks Reusable block library Prisma Site Settings SiteSettings site_settings Global site configuration singleton Prisma Media videos videos Video library with metadata Drizzle compilations compilations Video compilation tracking Drizzle jobs jobs Job queue with resource management Drizzle

Total: 33 models (30 Prisma + 3 Drizzle)

"},{"location":"v2/database/schema/#auth-users","title":"Auth & Users","text":""},{"location":"v2/database/schema/#user","title":"User","text":"

Table: users Description: User accounts with role-based access control, temporary user support, and audit tracking.

Field Type Required Default Description id String \u2713 cuid() Primary key email String \u2713 \u2014 Unique email address password String \u2713 \u2014 bcrypt hashed password (12+ chars policy) name String \u2717 null User display name phone String \u2717 null Phone number role UserRole \u2713 USER Role: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP status UserStatus \u2713 ACTIVE Status: ACTIVE, INACTIVE, SUSPENDED, EXPIRED permissions Json \u2717 null Granular per-app permissions object createdVia UserCreatedVia \u2713 STANDARD Creation source: ADMIN, PUBLIC_SHIFT_SIGNUP, STANDARD expiresAt DateTime \u2717 null Expiration date for TEMP users expireDays Int \u2717 null Days until expiration for TEMP users lastLoginAt DateTime \u2717 null Last login timestamp emailVerified Boolean \u2713 false Email verification status createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: - Unique: email

Relations (33 total): - refreshTokens \u2192 RefreshToken[] - campaignsCreated \u2192 Campaign[] - campaignEmails \u2192 CampaignEmail[] - responses \u2192 RepresentativeResponse[] - responseUpvotes \u2192 ResponseUpvote[] - shiftSignups \u2192 ShiftSignup[] - locationsCreated \u2192 Location[] - locationsUpdated \u2192 Location[] - addressesCreated \u2192 Address[] - addressesUpdated \u2192 Address[] - locationEdits \u2192 LocationHistory[] - cutsCreated \u2192 Cut[] - canvassVisits \u2192 CanvassVisit[] - canvassSessions \u2192 CanvassSession[] - trackingSessions \u2192 TrackingSession[] - templatesCreated \u2192 EmailTemplate[] - templatesUpdated \u2192 EmailTemplate[] - templateVersionsCreated \u2192 EmailTemplateVersion[] - templateTestsSent \u2192 EmailTemplateTestLog[]

"},{"location":"v2/database/schema/#refreshtoken","title":"RefreshToken","text":"

Table: refresh_tokens Description: JWT refresh token storage with expiration tracking.

Field Type Required Default Description id String \u2713 cuid() Primary key token String \u2713 \u2014 JWT refresh token (unique) userId String \u2713 \u2014 Foreign key to User expiresAt DateTime \u2713 \u2014 Token expiration timestamp createdAt DateTime \u2713 now() Creation timestamp

Indexes: - Unique: token - Foreign key: userId

Relations: - user \u2192 User (onDelete: Cascade)

"},{"location":"v2/database/schema/#influence","title":"Influence","text":""},{"location":"v2/database/schema/#campaign","title":"Campaign","text":"

Table: campaigns Description: Advocacy campaigns with 12 feature flags and government-level targeting.

Field Type Required Default Description id String \u2713 cuid() Primary key slug String \u2713 \u2014 URL-friendly slug (unique) title String \u2713 \u2014 Campaign title description String \u2717 null Campaign description (long text) emailSubject String \u2713 \u2014 Default email subject line emailBody String \u2713 \u2014 Default email body (long text) callToAction String \u2717 null Call-to-action text (long text) coverPhoto String \u2717 null Cover photo URL status CampaignStatus \u2713 DRAFT Status: DRAFT, ACTIVE, PAUSED, ARCHIVED allowSmtpEmail Boolean \u2713 true Allow SMTP email sending allowMailtoLink Boolean \u2713 true Allow mailto: links collectUserInfo Boolean \u2713 true Collect user information showEmailCount Boolean \u2713 true Show email sent count showCallCount Boolean \u2713 true Show call made count allowEmailEditing Boolean \u2713 false Allow users to edit email content allowCustomRecipients Boolean \u2713 false Allow custom email recipients showResponseWall Boolean \u2713 false Show public response wall highlightCampaign Boolean \u2713 false Highlight on campaign list page targetGovernmentLevels GovernmentLevel[] \u2713 [] Target levels: FEDERAL, PROVINCIAL, MUNICIPAL, SCHOOL_BOARD createdByUserId String \u2717 null Foreign key to User (creator) createdByUserEmail String \u2717 null Creator email (denormalized) createdByUserName String \u2717 null Creator name (denormalized) createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: - Unique: slug

Relations: - createdByUser \u2192 User (onDelete: SetNull) - emails \u2192 CampaignEmail[] - responses \u2192 RepresentativeResponse[] - customRecipients \u2192 CustomRecipient[] - calls \u2192 Call[]

"},{"location":"v2/database/schema/#representative","title":"Representative","text":"

Table: representatives Description: Cached representative data from Represent API.

Field Type Required Default Description id String \u2713 cuid() Primary key postalCode String \u2713 \u2014 Canadian postal code (indexed) name String \u2717 null Representative name email String \u2717 null Representative email districtName String \u2717 null Electoral district name electedOffice String \u2717 null Office title partyName String \u2717 null Political party representativeSetName String \u2717 null Representative set from Represent API url String \u2717 null Official website URL photoUrl String \u2717 null Photo URL offices Json \u2717 null Array of office contact info objects cachedAt DateTime \u2713 now() Cache timestamp

Indexes: - Non-unique: postalCode

Relations: None (standalone cache)

"},{"location":"v2/database/schema/#campaignemail","title":"CampaignEmail","text":"

Table: campaign_emails Description: Email tracking and delivery logs for campaign emails.

Field Type Required Default Description id String \u2713 cuid() Primary key campaignId String \u2713 \u2014 Foreign key to Campaign campaignSlug String \u2713 \u2014 Denormalized campaign slug userId String \u2717 null Foreign key to User (sender) userEmail String \u2717 null Sender email userName String \u2717 null Sender name userPostalCode String \u2717 null Sender postal code recipientEmail String \u2713 \u2014 Recipient email address recipientName String \u2717 null Recipient name recipientTitle String \u2717 null Recipient title recipientLevel GovernmentLevel \u2717 null Government level emailMethod EmailMethod \u2713 \u2014 Method: SMTP, MAILTO subject String \u2713 \u2014 Email subject line message String \u2713 \u2014 Email message body (long text) status CampaignEmailStatus \u2713 SENT Status: QUEUED, SENT, FAILED, CLICKED, USER_INFO_CAPTURED senderIp String \u2717 null Sender IP address sentAt DateTime \u2713 now() Send timestamp

Indexes: - Foreign key: campaignId - Non-unique: campaignSlug

Relations: - campaign \u2192 Campaign (onDelete: Cascade) - user \u2192 User (onDelete: SetNull)

"},{"location":"v2/database/schema/#representativeresponse","title":"RepresentativeResponse","text":"

Table: representative_responses Description: Response wall submissions with moderation workflow.

Field Type Required Default Description id String \u2713 cuid() Primary key campaignId String \u2713 \u2014 Foreign key to Campaign campaignSlug String \u2713 \u2014 Denormalized campaign slug representativeName String \u2713 \u2014 Representative name representativeTitle String \u2717 null Representative title representativeLevel GovernmentLevel \u2713 \u2014 Government level representativeEmail String \u2717 null Representative email responseType ResponseType \u2713 \u2014 Type: EMAIL, LETTER, PHONE_CALL, MEETING, SOCIAL_MEDIA, OTHER responseText String \u2713 \u2014 Response text (long text) userComment String \u2717 null User comment (long text) screenshotUrl String \u2717 null Screenshot URL submittedByUserId String \u2717 null Foreign key to User submittedByName String \u2717 null Submitter name submittedByEmail String \u2717 null Submitter email isAnonymous Boolean \u2713 false Anonymous submission flag status ResponseStatus \u2713 PENDING Status: PENDING, APPROVED, REJECTED isVerified Boolean \u2713 false Email verification status verificationToken String \u2717 null Verification token verificationSentAt DateTime \u2717 null Verification email timestamp verifiedAt DateTime \u2717 null Verification timestamp verifiedBy String \u2717 null Email address that verified upvoteCount Int \u2713 0 Upvote count (denormalized) submittedIp String \u2717 null Submitter IP address createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: - Foreign key: campaignId - Non-unique: campaignSlug

Relations: - campaign \u2192 Campaign (onDelete: Cascade) - submittedByUser \u2192 User (onDelete: SetNull) - upvotes \u2192 ResponseUpvote[]

"},{"location":"v2/database/schema/#responseupvote","title":"ResponseUpvote","text":"

Table: response_upvotes Description: Upvote tracking with deduplication by user ID and IP address.

Field Type Required Default Description id String \u2713 cuid() Primary key responseId String \u2713 \u2014 Foreign key to RepresentativeResponse userId String \u2717 null Foreign key to User userEmail String \u2717 null User email (for guest upvotes) upvotedIp String \u2717 null Upvoter IP address

Indexes: - Unique: [responseId, userId] (prevent duplicate upvotes from logged-in users) - Unique: [responseId, upvotedIp] (prevent duplicate upvotes from same IP)

Relations: - response \u2192 RepresentativeResponse (onDelete: Cascade) - user \u2192 User (onDelete: SetNull)

"},{"location":"v2/database/schema/#customrecipient","title":"CustomRecipient","text":"

Table: custom_recipients Description: Custom email targets for campaigns (when allowCustomRecipients enabled).

Field Type Required Default Description id String \u2713 cuid() Primary key campaignId String \u2713 \u2014 Foreign key to Campaign campaignSlug String \u2713 \u2014 Denormalized campaign slug recipientName String \u2713 \u2014 Recipient name recipientEmail String \u2713 \u2014 Recipient email address recipientTitle String \u2717 null Recipient title recipientOrganization String \u2717 null Recipient organization notes String \u2717 null Admin notes (long text) isActive Boolean \u2713 true Active status createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: - Foreign key: campaignId

Relations: - campaign \u2192 Campaign (onDelete: Cascade)

"},{"location":"v2/database/schema/#postalcodecache","title":"PostalCodeCache","text":"

Table: postal_code_cache Description: Postal code geocoding cache for centroid lookups.

Field Type Required Default Description id String \u2713 cuid() Primary key postalCode String \u2713 \u2014 Canadian postal code (unique) city String \u2717 null City name province String \u2717 null Province code (e.g., \"AB\") centroidLat Decimal(10,8) \u2717 null Centroid latitude centroidLng Decimal(11,8) \u2717 null Centroid longitude lastUpdated DateTime \u2713 now() Last cache update

Indexes: - Unique: postalCode

Relations: None (standalone cache)

"},{"location":"v2/database/schema/#emaillog","title":"EmailLog","text":"

Table: email_logs Description: Global email audit trail (all email types).

Field Type Required Default Description id String \u2713 cuid() Primary key recipientEmail String \u2713 \u2014 Recipient email address senderName String \u2713 \u2014 Sender name senderEmail String \u2713 \u2014 Sender email address subject String \u2717 null Email subject line message String \u2717 null Email message body (long text) postalCode String \u2717 null Sender postal code status String \u2713 \"sent\" Status: sent, failed, previewed senderIp String \u2717 null Sender IP address sentAt DateTime \u2713 now() Send timestamp

Indexes: None

Relations: None (audit log only)

"},{"location":"v2/database/schema/#emailverification","title":"EmailVerification","text":"

Table: email_verifications Description: Email verification tokens for response wall submissions.

Field Type Required Default Description id String \u2713 cuid() Primary key token String \u2713 \u2014 Verification token (unique) email String \u2713 \u2014 Email address to verify tempCampaignData String \u2717 null Temporary campaign data JSON (long text) createdAt DateTime \u2713 now() Creation timestamp expiresAt DateTime \u2713 \u2014 Token expiration timestamp used Boolean \u2713 false Token used flag

Indexes: - Unique: token

Relations: None (standalone)

"},{"location":"v2/database/schema/#call","title":"Call","text":"

Table: calls Description: Phone call tracking for advocacy campaigns.

Field Type Required Default Description id String \u2713 cuid() Primary key representativeName String \u2713 \u2014 Representative name representativeTitle String \u2717 null Representative title phoneNumber String \u2713 \u2014 Phone number called officeType String \u2717 null Office type (constituency, legislative, etc.) callerName String \u2717 null Caller name callerEmail String \u2717 null Caller email postalCode String \u2717 null Caller postal code campaignId String \u2717 null Foreign key to Campaign campaignSlug String \u2717 null Denormalized campaign slug callerIp String \u2717 null Caller IP address calledAt DateTime \u2713 now() Call timestamp

Indexes: - Foreign key: campaignId

Relations: - campaign \u2192 Campaign (onDelete: SetNull)

"},{"location":"v2/database/schema/#map-locations","title":"Map \u2014 Locations","text":""},{"location":"v2/database/schema/#location","title":"Location","text":"

Table: locations Description: Building-level address data with geocoding and NAR integration.

Field Type Required Default Description id String \u2713 cuid() Primary key latitude Decimal(10,8) \u2713 \u2014 Latitude coordinate (required) longitude Decimal(11,8) \u2713 \u2014 Longitude coordinate (required) address String \u2713 \u2014 Base street address (no unit number) postalCode String \u2717 null Canadian postal code province String \u2717 null Province code (e.g., \"AB\") federalDistrict String \u2717 null Federal electoral district name buildingUse Int \u2717 null NAR BU_USE: 1=Residential, 2=Partial, 3=Non-Residential, 4=Unknown locGuid String \u2717 null NAR LOC_GUID (unique) buildingType BuildingType \u2713 SINGLE_FAMILY Type: SINGLE_FAMILY, MULTI_UNIT, MIXED_USE, COMMERCIAL totalUnits Int \u2713 1 Total units in building buildingNotes String \u2717 null Access codes, manager contact (long text) geocodeConfidence Int \u2717 null Geocoding confidence (0-100) geocodeProvider GeocodeProvider \u2717 null Provider: GOOGLE, MAPBOX, NOMINATIM, PHOTON, LOCATIONIQ, ARCGIS, UNKNOWN createdByUserId String \u2717 null Foreign key to User (creator) updatedByUserId String \u2717 null Foreign key to User (last updater) createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: - Unique: locGuid - Composite: [latitude, longitude] (spatial queries) - Non-unique: postalCode

Relations: - createdByUser \u2192 User (onDelete: SetNull) - updatedByUser \u2192 User (onDelete: SetNull) - addresses \u2192 Address[] - history \u2192 LocationHistory[]

"},{"location":"v2/database/schema/#address","title":"Address","text":"

Table: addresses Description: Unit-level data with support levels and canvassing information.

Field Type Required Default Description id String \u2713 cuid() Primary key locationId String \u2713 \u2014 Foreign key to Location unitNumber String \u2717 null Unit/apartment number addrGuid String \u2717 null NAR ADDR_GUID (unique) firstName String \u2717 null Occupant first name lastName String \u2717 null Occupant last name email String \u2717 null Occupant email phone String \u2717 null Occupant phone supportLevel SupportLevel \u2717 null Support level: 1, 2, 3, 4 sign Boolean \u2713 false Sign requested flag signSize String \u2717 null Sign size notes String \u2717 null Canvassing notes (long text) createdByUserId String \u2717 null Foreign key to User (creator) updatedByUserId String \u2717 null Foreign key to User (last updater) createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: - Unique: addrGuid - Foreign key: locationId - Composite: [locationId, unitNumber] (unit lookups)

Relations: - location \u2192 Location (onDelete: Cascade) - createdByUser \u2192 User (onDelete: SetNull) - updatedByUser \u2192 User (onDelete: SetNull) - canvassVisits \u2192 CanvassVisit[]

"},{"location":"v2/database/schema/#locationhistory","title":"LocationHistory","text":"

Table: location_history Description: Audit trail for location changes with action types.

Field Type Required Default Description id String \u2713 cuid() Primary key locationId String \u2713 \u2014 Foreign key to Location userId String \u2717 null Foreign key to User action LocationHistoryAction \u2713 \u2014 Action: CREATED, UPDATED, GEOCODED, BULK_GEOCODED, MOVED_ON_MAP, IMPORTED_CSV, IMPORTED_NAR field String \u2717 null Field name that changed oldValue String \u2717 null Old value (long text) newValue String \u2717 null New value (long text) metadata Json \u2717 null Provider, confidence, etc. createdAt DateTime \u2713 now() Timestamp

Indexes: - Foreign key: locationId - Foreign key: userId - Non-unique: createdAt (temporal queries)

Relations: - location \u2192 Location (onDelete: Cascade) - user \u2192 User (onDelete: SetNull)

"},{"location":"v2/database/schema/#map-shifts-cuts","title":"Map \u2014 Shifts & Cuts","text":""},{"location":"v2/database/schema/#shift","title":"Shift","text":"

Table: shifts Description: Volunteer shifts with scheduling and capacity tracking.

Field Type Required Default Description id String \u2713 cuid() Primary key title String \u2713 \u2014 Shift title description String \u2717 null Shift description (long text) date DateTime \u2713 \u2014 Shift date (date only, no time) startTime String \u2713 \u2014 Start time (HH:MM format) endTime String \u2713 \u2014 End time (HH:MM format) location String \u2717 null Shift location description maxVolunteers Int \u2713 \u2014 Maximum volunteer capacity currentVolunteers Int \u2713 0 Current signup count status ShiftStatus \u2713 OPEN Status: OPEN, FULL, CANCELLED isPublic Boolean \u2713 false Public signup allowed cutId String \u2717 null Foreign key to Cut createdBy String \u2717 null Creator user ID (string, not FK) createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: - Foreign key: cutId

Relations: - cut \u2192 Cut (onDelete: SetNull) - signups \u2192 ShiftSignup[] - canvassVisits \u2192 CanvassVisit[] - canvassSessions \u2192 CanvassSession[]

"},{"location":"v2/database/schema/#shiftsignup","title":"ShiftSignup","text":"

Table: shift_signups Description: Shift signup tracking with source attribution.

Field Type Required Default Description id String \u2713 cuid() Primary key shiftId String \u2713 \u2014 Foreign key to Shift shiftTitle String \u2717 null Denormalized shift title userId String \u2717 null Foreign key to User userEmail String \u2713 \u2014 User email (for guest signups) userName String \u2717 null User name userPhone String \u2717 null User phone signupDate DateTime \u2713 now() Signup timestamp status SignupStatus \u2713 CONFIRMED Status: CONFIRMED, CANCELLED signupSource SignupSource \u2713 AUTHENTICATED Source: AUTHENTICATED, PUBLIC, ADMIN

Indexes: - Unique: [shiftId, userEmail] (prevent duplicate signups) - Foreign key: shiftId

Relations: - shift \u2192 Shift (onDelete: Cascade) - user \u2192 User (onDelete: SetNull)

"},{"location":"v2/database/schema/#cut","title":"Cut","text":"

Table: cuts Description: GeoJSON polygon overlays for map filtering and canvassing.

Field Type Required Default Description id String \u2713 cuid() Primary key name String \u2713 \u2014 Cut name description String \u2717 null Cut description (long text) color String \u2713 \"#3388ff\" Polygon fill color (hex) opacity Decimal(3,2) \u2713 0.3 Polygon opacity (0.00-1.00) category CutCategory \u2717 null Category: CUSTOM, WARD, NEIGHBORHOOD, DISTRICT isPublic Boolean \u2713 false Public visibility flag isOfficial Boolean \u2713 false Official boundary flag geojson String \u2713 \u2014 GeoJSON polygon data (long text) bounds String \u2717 null Bounding box JSON (long text) showLocations Boolean \u2713 true Show locations on map exportEnabled Boolean \u2713 true Export enabled flag assignedTo String \u2717 null Assigned user ID (string, not FK) filterSettings Json \u2717 null Filter configuration object lastCanvassed DateTime \u2717 null Last canvass timestamp completionPercentage Int \u2713 0 Canvass completion percentage createdByUserId String \u2717 null Foreign key to User (creator) createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: None

Relations: - createdByUser \u2192 User (onDelete: SetNull) - shifts \u2192 Shift[] - canvassSessions \u2192 CanvassSession[]

"},{"location":"v2/database/schema/#mapsettings","title":"MapSettings","text":"

Table: map_settings Description: Singleton for map center/zoom and walk sheet configuration.

Field Type Required Default Description id String \u2713 cuid() Primary key (always \"default\") latitude Decimal(10,8) \u2717 null Map center latitude longitude Decimal(11,8) \u2717 null Map center longitude zoom Int \u2717 null Default map zoom level walkSheetTitle String \u2717 null Walk sheet header title walkSheetSubtitle String \u2717 null Walk sheet header subtitle walkSheetFooter String \u2717 null Walk sheet footer text (long text) qrCode1Url String \u2717 null QR code 1 URL qrCode1Label String \u2717 null QR code 1 label qrCode2Url String \u2717 null QR code 2 URL qrCode2Label String \u2717 null QR code 2 label qrCode3Url String \u2717 null QR code 3 URL qrCode3Label String \u2717 null QR code 3 label createdBy String \u2717 null Creator user ID (string, not FK) createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: None

Relations: None (singleton)

"},{"location":"v2/database/schema/#canvassing","title":"Canvassing","text":""},{"location":"v2/database/schema/#canvasssession","title":"CanvassSession","text":"

Table: canvass_sessions Description: Canvassing session lifecycle with status tracking.

Field Type Required Default Description id String \u2713 cuid() Primary key userId String \u2713 \u2014 Foreign key to User cutId String \u2713 \u2014 Foreign key to Cut shiftId String \u2717 null Foreign key to Shift status CanvassSessionStatus \u2713 ACTIVE Status: ACTIVE, COMPLETED, ABANDONED startedAt DateTime \u2713 now() Session start timestamp endedAt DateTime \u2717 null Session end timestamp startLatitude Decimal(10,8) \u2717 null Starting latitude startLongitude Decimal(11,8) \u2717 null Starting longitude

Indexes: - Foreign key: userId - Foreign key: cutId - Foreign key: shiftId

Relations: - user \u2192 User (onDelete: Cascade) - cut \u2192 Cut (onDelete: Cascade) - shift \u2192 Shift (onDelete: SetNull) - visits \u2192 CanvassVisit[] - trackingSession \u2192 TrackingSession (one-to-one)

"},{"location":"v2/database/schema/#canvassvisit","title":"CanvassVisit","text":"

Table: canvass_visits Description: Visit recording with outcome tracking.

Field Type Required Default Description id String \u2713 cuid() Primary key addressId String \u2713 \u2014 Foreign key to Address userId String \u2713 \u2014 Foreign key to User shiftId String \u2717 null Foreign key to Shift sessionId String \u2717 null Foreign key to CanvassSession outcome VisitOutcome \u2713 \u2014 Outcome: NOT_HOME, REFUSED, MOVED, ALREADY_VOTED, SPOKE_WITH, LEFT_LITERATURE, COME_BACK_LATER supportLevel SupportLevel \u2717 null Support level: 1, 2, 3, 4 signRequested Boolean \u2713 false Sign requested flag signSize String \u2717 null Sign size notes String \u2717 null Visit notes (long text) durationSeconds Int \u2717 null Visit duration in seconds visitedAt DateTime \u2713 now() Visit timestamp

Indexes: - Foreign key: addressId - Foreign key: userId - Foreign key: shiftId - Foreign key: sessionId - Non-unique: visitedAt (temporal queries)

Relations: - address \u2192 Address (onDelete: Cascade) - user \u2192 User (onDelete: Cascade) - shift \u2192 Shift (onDelete: SetNull) - session \u2192 CanvassSession (onDelete: SetNull)

"},{"location":"v2/database/schema/#trackingsession","title":"TrackingSession","text":"

Table: tracking_sessions Description: GPS tracking sessions with distance calculation.

Field Type Required Default Description id String \u2713 cuid() Primary key userId String \u2713 \u2014 Foreign key to User canvassSessionId String \u2717 null Foreign key to CanvassSession (unique, one-to-one) startedAt DateTime \u2713 now() Tracking start timestamp endedAt DateTime \u2717 null Tracking end timestamp isActive Boolean \u2713 true Active tracking flag totalPoints Int \u2713 0 Total GPS points recorded totalDistanceM Float \u2713 0 Total distance in meters lastLatitude Decimal(10,8) \u2717 null Last recorded latitude lastLongitude Decimal(11,8) \u2717 null Last recorded longitude lastRecordedAt DateTime \u2717 null Last GPS point timestamp

Indexes: - Unique: canvassSessionId - Foreign key: userId - Non-unique: isActive - Composite: [isActive, lastRecordedAt] (cleanup queries)

Relations: - user \u2192 User (onDelete: Cascade) - canvassSession \u2192 CanvassSession (onDelete: SetNull) - trackPoints \u2192 TrackPoint[]

"},{"location":"v2/database/schema/#trackpoint","title":"TrackPoint","text":"

Table: track_points Description: GPS breadcrumb trail with event types.

Field Type Required Default Description id String \u2713 cuid() Primary key trackingSessionId String \u2713 \u2014 Foreign key to TrackingSession latitude Decimal(10,8) \u2713 \u2014 GPS latitude longitude Decimal(11,8) \u2713 \u2014 GPS longitude accuracy Float \u2717 null GPS accuracy in meters recordedAt DateTime \u2713 now() GPS point timestamp eventType TrackPointEvent \u2717 null Event: LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED

Indexes: - Composite: [trackingSessionId, recordedAt] (temporal queries) - Non-unique: recordedAt

Relations: - trackingSession \u2192 TrackingSession (onDelete: Cascade)

"},{"location":"v2/database/schema/#email-templates","title":"Email Templates","text":""},{"location":"v2/database/schema/#emailtemplate","title":"EmailTemplate","text":"

Table: email_templates Description: Email template master records with category organization.

Field Type Required Default Description id String \u2713 cuid() Primary key key String \u2713 \u2014 Template key (unique, e.g., \"campaign-email\") name String \u2713 \u2014 Display name description String \u2717 null Template description (long text) category EmailTemplateCategory \u2713 \u2014 Category: INFLUENCE, MAP, SYSTEM subjectLine String \u2713 \u2014 Subject line with {{VAR}} support htmlContent String \u2713 \u2014 HTML template (long text) textContent String \u2713 \u2014 Plain text template (long text) isSystem Boolean \u2713 false System template (prevent deletion) isActive Boolean \u2713 true Active status createdByUserId String \u2713 \u2014 Foreign key to User (creator) updatedByUserId String \u2717 null Foreign key to User (last updater) createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: - Unique: key - Non-unique: category - Non-unique: isActive

Relations: - createdBy \u2192 User - updatedBy \u2192 User - variables \u2192 EmailTemplateVariable[] - versions \u2192 EmailTemplateVersion[] - testLogs \u2192 EmailTemplateTestLog[]

"},{"location":"v2/database/schema/#emailtemplatevariable","title":"EmailTemplateVariable","text":"

Table: email_template_variables Description: Template variable definitions with validation.

Field Type Required Default Description id String \u2713 cuid() Primary key templateId String \u2713 \u2014 Foreign key to EmailTemplate key String \u2713 \u2014 Variable key (e.g., \"USER_NAME\") label String \u2713 \u2014 Variable label (e.g., \"User Name\") description String \u2717 null Variable description (long text) isRequired Boolean \u2713 true Required flag isConditional Boolean \u2713 false Conditional variable (used in {{#if}}) sampleValue String \u2717 null Sample value for testing (long text) sortOrder Int \u2713 0 Display order

Indexes: - Unique: [templateId, key] (unique variable keys per template) - Foreign key: templateId

Relations: - template \u2192 EmailTemplate (onDelete: Cascade)

"},{"location":"v2/database/schema/#emailtemplateversion","title":"EmailTemplateVersion","text":"

Table: email_template_versions Description: Template version history with auto-increment version numbers.

Field Type Required Default Description id String \u2713 cuid() Primary key templateId String \u2713 \u2014 Foreign key to EmailTemplate versionNumber Int \u2713 \u2014 Auto-increment version number per template subjectLine String \u2713 \u2014 Subject line snapshot htmlContent String \u2713 \u2014 HTML content snapshot (long text) textContent String \u2713 \u2014 Plain text snapshot (long text) changeNotes String \u2717 null Version notes (long text) createdByUserId String \u2713 \u2014 Foreign key to User createdAt DateTime \u2713 now() Version timestamp

Indexes: - Unique: [templateId, versionNumber] (sequential version numbers) - Composite: [templateId, createdAt(sort: Desc)] (recent versions)

Relations: - template \u2192 EmailTemplate (onDelete: Cascade) - createdBy \u2192 User

"},{"location":"v2/database/schema/#emailtemplatetestlog","title":"EmailTemplateTestLog","text":"

Table: email_template_test_logs Description: Test email audit logs.

Field Type Required Default Description id String \u2713 cuid() Primary key templateId String \u2713 \u2014 Foreign key to EmailTemplate recipientEmail String \u2713 \u2014 Test recipient email testData Json \u2713 \u2014 Sample variable values JSON success Boolean \u2713 \u2014 Test success flag errorMessage String \u2717 null Error message (long text) messageId String \u2717 null Nodemailer message ID sentByUserId String \u2713 \u2014 Foreign key to User sentAt DateTime \u2713 now() Send timestamp

Indexes: - Composite: [templateId, sentAt(sort: Desc)] (recent tests)

Relations: - template \u2192 EmailTemplate (onDelete: Cascade) - sentBy \u2192 User

"},{"location":"v2/database/schema/#landing-pages","title":"Landing Pages","text":""},{"location":"v2/database/schema/#landingpage","title":"LandingPage","text":"

Table: landing_pages Description: GrapesJS editor output with MkDocs export support.

Field Type Required Default Description id String \u2713 cuid() Primary key slug String \u2713 \u2014 URL slug (unique) title String \u2713 \u2014 Page title description String \u2717 null Page description (long text) blocks Json \u2713 \u2014 GrapesJS editor JSON htmlOutput String \u2717 null Rendered HTML (long text) cssOutput String \u2717 null Rendered CSS (long text) editorMode EditorMode \u2713 VISUAL Editor mode: VISUAL, CODE mkdocsPath String \u2717 null Path in mkdocs/overrides/ mkdocsStubPath String \u2717 null Path to .md stub in mkdocs/docs/ mkdocsExportMode MkdocsExportMode \u2713 THEMED Export mode: THEMED, STANDALONE mkdocsHideNav Boolean \u2713 true Hide navigation in MkDocs mkdocsHideToc Boolean \u2713 true Hide table of contents in MkDocs mkdocsSkipExport Boolean \u2713 false Skip MkDocs export flag published Boolean \u2713 false Published status seoTitle String \u2717 null SEO title override seoDescription String \u2717 null SEO description (long text) seoImage String \u2717 null SEO image URL createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: - Unique: slug

Relations: None

"},{"location":"v2/database/schema/#pageblock","title":"PageBlock","text":"

Table: page_blocks Description: Reusable block library for GrapesJS editor.

Field Type Required Default Description id String \u2713 cuid() Primary key type String \u2713 \u2014 Block type (hero, text, image, cta, features, testimonials, form) label String \u2713 \u2014 Block label schema Json \u2713 \u2014 Block configuration schema JSON defaults Json \u2713 \u2014 Default values JSON thumbnail String \u2717 null Thumbnail URL category String \u2717 null Block category sortOrder Int \u2713 0 Display order createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: None

Relations: None

"},{"location":"v2/database/schema/#site-settings","title":"Site Settings","text":""},{"location":"v2/database/schema/#sitesettings","title":"SiteSettings","text":"

Table: site_settings Description: Global site configuration singleton for branding, theme, SMTP, and feature toggles.

Field Type Required Default Description id String \u2713 cuid() Primary key (always \"default\") organizationName String \u2713 \"Changemaker Lite\" Organization name organizationShortName String \u2713 \"CML\" Short name/acronym organizationLogoUrl String \u2717 null Logo URL organizationFaviconUrl String \u2717 null Favicon URL adminColorPrimary String \u2713 \"#9d4edd\" Admin primary color (hex) adminColorBgBase String \u2713 \"#1a1025\" Admin background color (hex) publicColorPrimary String \u2713 \"#3498db\" Public primary color (hex) publicColorBgBase String \u2713 \"#0d1b2a\" Public background color (hex) publicColorBgContainer String \u2713 \"#1b2838\" Public container color (hex) publicHeaderGradient String \u2713 \"linear-gradient(135deg, #005a9c 0%, #007acc 100%)\" Public header gradient (CSS) footerText String \u2713 \"Powered by Changemaker Lite\" Footer text loginSubtitle String \u2713 \"Admin\" Login page subtitle emailFromName String \u2713 \"Changemaker Lite\" Email from name smtpHost String \u2713 \"\" SMTP host (empty = use env) smtpPort Int \u2713 0 SMTP port (0 = use env) smtpUser String \u2713 \"\" SMTP username (empty = use env) smtpPass String \u2713 \"\" SMTP password (empty = use env) smtpFromAddress String \u2713 \"\" SMTP from address (empty = use env) smtpActiveProvider String \u2713 \"mailhog\" Active provider: \"mailhog\", \"production\" emailTestMode Boolean \u2713 true Email test mode flag testEmailRecipient String \u2713 \"\" Test email recipient enableInfluence Boolean \u2713 true Enable Influence module enableMap Boolean \u2713 true Enable Map module enableNewsletter Boolean \u2713 true Enable Newsletter module enableLandingPages Boolean \u2713 true Enable Landing Pages module createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: None

Relations: None (singleton)

"},{"location":"v2/database/schema/#media-drizzle-orm","title":"Media (Drizzle ORM)","text":""},{"location":"v2/database/schema/#videos","title":"videos","text":"

Table: videos Description: Video library with metadata extraction and engagement tracking.

Field Type Required Default Description id serial \u2713 Auto Primary key (auto-increment) path text \u2713 \u2014 File path (unique) filename text \u2713 \u2014 File name producer text \u2717 null Producer name creator text \u2717 null Creator name title text \u2717 null Video title durationSeconds integer \u2717 null Duration in seconds (FFprobe) quality text \u2717 null Quality string (e.g., \"1080p\") orientation text \u2717 null Orientation: portrait, landscape, square hasAudio boolean \u2713 true Audio track present flag fileSize bigint \u2717 null File size in bytes fileHash text \u2717 null MD5 hash width integer \u2717 null Video width (FFprobe) height integer \u2717 null Video height (FFprobe) lastValidated timestamp \u2717 null Last validation timestamp isValid boolean \u2713 true Valid file flag thumbnailPath text \u2717 null Thumbnail file path createdAt timestamp \u2713 now() Creation timestamp tags jsonb \u2717 null Array of tag strings directoryType text \u2717 null Directory type: studios, gifs, private, inbox, curated, playback, compilations, videos, highlights publicViewCount integer \u2717 null Public view count (historical) publicUpvoteCount integer \u2717 null Public upvote count (historical) publicCommentCount integer \u2717 null Public comment count (historical) publicCompletionCount integer \u2717 null Public completion count (historical) publicTotalWatchTime integer \u2717 null Public total watch time (historical) movedFromPublicAt timestamp \u2717 null Timestamp when moved from public media originalFilename text \u2717 null Original filename before standardization originalPath text \u2717 null Original path before standardization standardizedAt timestamp \u2717 null Standardization timestamp

Indexes: - Unique: path - Non-unique: orientation - Non-unique: producer - Non-unique: isValid - Non-unique: directoryType - Composite: [durationSeconds, fileSize, width, height] (fingerprint) - Composite: [directoryType, isValid, orientation] (common filtering)

Relations: None (standalone)

"},{"location":"v2/database/schema/#compilations","title":"compilations","text":"

Table: compilations Description: Video compilation tracking.

Field Type Required Default Description id serial \u2713 Auto Primary key (auto-increment) filename text \u2713 \u2014 Compilation filename path text \u2717 null Compilation file path durationSeconds integer \u2717 null Total duration in seconds videoIds jsonb \u2717 null Array of video IDs included settings jsonb \u2717 null Compilation settings object createdAt timestamp \u2713 now() Creation timestamp

Indexes: None

Relations: None (video IDs stored as JSON array)

"},{"location":"v2/database/schema/#jobs","title":"jobs","text":"

Table: jobs Description: Job queue with resource category management.

Field Type Required Default Description id serial \u2713 Auto Primary key (auto-increment) type text \u2713 \u2014 Job type (compilation, scan, organize, etc.) status text \u2713 \"pending\" Status: pending, queued, running, completed, failed, cancelled progress integer \u2713 0 Progress percentage (0-100) log text \u2717 null Job log output params jsonb \u2717 null Job parameters object startedAt timestamp \u2717 null Job start timestamp completedAt timestamp \u2717 null Job completion timestamp createdAt timestamp \u2713 now() Creation timestamp resourceCategory text \u2713 \"cpu\" Resource category: gpu_ai, gpu_encode, cpu vramRequired integer \u2713 0 VRAM required in MB queuePosition integer \u2717 null Queue position waitingReason text \u2717 null Reason for waiting priority integer \u2713 5 Job priority (lower = higher priority) pipelineId integer \u2717 null Pipeline ID (for pipeline jobs) pipelineStepId integer \u2717 null Pipeline step ID

Indexes: - Composite: [status, priority, createdAt] (queue processing) - Composite: [resourceCategory, status] (resource filtering) - Non-unique: pipelineId

Relations: None (pipeline relations are external)

"},{"location":"v2/database/schema/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/database/seeding/","title":"Database Seeding","text":""},{"location":"v2/database/seeding/#overview","title":"Overview","text":"

The database seeding process populates initial data required for the application to function. Seeding runs automatically after migrations in development but must be run manually in production.

Seed Script: api/prisma/seed.ts

Seed Data: - Default super admin user - Default map settings (Edmonton coordinates) - 6 page blocks for landing page builder - 4 email templates (campaign email, response verification, shift signup confirmation, shift details reminder)

"},{"location":"v2/database/seeding/#running-seed","title":"Running Seed","text":""},{"location":"v2/database/seeding/#development","title":"Development","text":"
cd api\nnpm run seed\n# OR\nnpx prisma db seed\n
"},{"location":"v2/database/seeding/#production-docker","title":"Production (Docker)","text":"
docker compose exec api npx prisma db seed\n
"},{"location":"v2/database/seeding/#cicd","title":"CI/CD","text":"

Seed runs automatically after prisma migrate deploy if configured in package.json:

{\n  \"prisma\": {\n    \"seed\": \"ts-node prisma/seed.ts\"\n  }\n}\n

"},{"location":"v2/database/seeding/#seed-data-details","title":"Seed Data Details","text":""},{"location":"v2/database/seeding/#1-default-admin-user","title":"1. Default Admin User","text":"

Email: admin@cmlite.org Password: ChangeMe2025! Role: SUPER_ADMIN Status: ACTIVE Email Verified: true

Code:

const hashedPassword = await bcrypt.hash('ChangeMe2025!', 10);\n\nconst admin = await prisma.user.upsert({\n  where: { email: 'admin@cmlite.org' },\n  update: {\n    password: hashedPassword,\n    emailVerified: true,\n    status: 'ACTIVE',\n  },\n  create: {\n    email: 'admin@cmlite.org',\n    password: hashedPassword,\n    name: 'Admin',\n    role: UserRole.SUPER_ADMIN,\n    emailVerified: true,\n  },\n});\n

Security Note: Change default password immediately after first login!

"},{"location":"v2/database/seeding/#2-default-map-settings","title":"2. Default Map Settings","text":"

ID: default (singleton) Coordinates: Edmonton, AB (53.5461\u00b0N, 113.4938\u00b0W) Zoom: 11 Walk Sheet: Blank titles/footers

Code:

await prisma.mapSettings.upsert({\n  where: { id: 'default' },\n  update: {},\n  create: {\n    id: 'default',\n    latitude: 53.5461,\n    longitude: -113.4938,\n    zoom: 11,\n    walkSheetTitle: 'Walk Sheet',\n    walkSheetSubtitle: '',\n    walkSheetFooter: '',\n  },\n});\n

"},{"location":"v2/database/seeding/#3-page-blocks-6-blocks","title":"3. Page Blocks (6 blocks)","text":""},{"location":"v2/database/seeding/#hero-section","title":"Hero Section","text":"
{\n  id: 'default-hero',\n  type: 'hero',\n  label: 'Hero Section',\n  category: 'Headers',\n  sortOrder: 1,\n  schema: {\n    title: { type: 'string', label: 'Title' },\n    subtitle: { type: 'string', label: 'Subtitle' },\n    backgroundImage: { type: 'string', label: 'Background Image URL' },\n    ctaText: { type: 'string', label: 'Button Text' },\n    ctaUrl: { type: 'string', label: 'Button URL' },\n  },\n  defaults: {\n    title: 'Welcome to Our Campaign',\n    subtitle: 'Join us in making a difference in your community.',\n    backgroundImage: '',\n    ctaText: 'Get Involved',\n    ctaUrl: '#',\n  },\n}\n
"},{"location":"v2/database/seeding/#text-block","title":"Text Block","text":"
{\n  id: 'default-text',\n  type: 'text',\n  label: 'Text Block',\n  category: 'Content',\n  sortOrder: 2,\n  schema: {\n    heading: { type: 'string', label: 'Heading' },\n    body: { type: 'text', label: 'Body Text' },\n  },\n  defaults: {\n    heading: 'About Us',\n    body: 'Tell your story here...',\n  },\n}\n
"},{"location":"v2/database/seeding/#features-grid","title":"Features Grid","text":"
{\n  id: 'default-features',\n  type: 'features',\n  label: 'Features Grid',\n  category: 'Content',\n  sortOrder: 3,\n  schema: {\n    features: {\n      type: 'array',\n      label: 'Features',\n      items: { title: 'string', description: 'string', icon: 'string' }\n    },\n  },\n  defaults: {\n    features: [\n      { title: 'Community Action', description: 'Organize local events...', icon: '' },\n      { title: 'Advocacy', description: 'Email your representatives...', icon: '' },\n      { title: 'Volunteer', description: 'Sign up for shifts...', icon: '' },\n    ],\n  },\n}\n
"},{"location":"v2/database/seeding/#call-to-action","title":"Call to Action","text":"
{\n  id: 'default-cta',\n  type: 'cta',\n  label: 'Call to Action',\n  category: 'Actions',\n  sortOrder: 4,\n  // ... (see seed.ts for full schema)\n}\n
"},{"location":"v2/database/seeding/#testimonials","title":"Testimonials","text":"
{\n  id: 'default-testimonials',\n  type: 'testimonials',\n  label: 'Testimonials',\n  category: 'Content',\n  sortOrder: 5,\n  // ... (see seed.ts for full schema)\n}\n
"},{"location":"v2/database/seeding/#contact-form","title":"Contact Form","text":"
{\n  id: 'default-contact-form',\n  type: 'contact-form',\n  label: 'Contact Form',\n  category: 'Actions',\n  sortOrder: 6,\n  // ... (see seed.ts for full schema)\n}\n
"},{"location":"v2/database/seeding/#4-email-templates-4-templates","title":"4. Email Templates (4 templates)","text":""},{"location":"v2/database/seeding/#campaign-email-to-representative","title":"Campaign Email to Representative","text":"

Key: campaign-email Category: INFLUENCE Variables: CAMPAIGN_TITLE, MESSAGE, USER_NAME, USER_EMAIL, POSTAL_CODE, RECIPIENT_NAME, RECIPIENT_LEVEL, ORGANIZATION_NAME, TIMESTAMP

File Locations: - HTML: api/src/templates/email/campaign-email.html - Text: api/src/templates/email/campaign-email.txt

Seeding Logic:

const templateDef = {\n  key: 'campaign-email',\n  name: 'Campaign Email to Representative',\n  description: 'Email sent when a constituent contacts their elected representative through a campaign',\n  category: EmailTemplateCategory.INFLUENCE,\n  subjectLine: '{{CAMPAIGN_TITLE}} - Message from {{USER_NAME}}',\n  isSystem: true,\n  variables: [\n    { key: 'CAMPAIGN_TITLE', label: 'Campaign Title', isRequired: true, sampleValue: 'Support Climate Action Bill C-12' },\n    { key: 'MESSAGE', label: 'Message Body', isRequired: true, sampleValue: 'I urge you to support...' },\n    // ... 7 more variables\n  ],\n};\n\nconst htmlContent = fs.readFileSync(path.join(templatesDir, `${templateDef.key}.html`), 'utf-8');\nconst textContent = fs.readFileSync(path.join(templatesDir, `${templateDef.key}.txt`), 'utf-8');\n\nconst template = await prisma.emailTemplate.create({\n  data: {\n    ...templateDef,\n    htmlContent,\n    textContent,\n    createdByUserId: admin.id,\n    variables: {\n      create: templateDef.variables,\n    },\n  },\n});\n

"},{"location":"v2/database/seeding/#response-verification","title":"Response Verification","text":"

Key: response-verification Category: INFLUENCE Variables: CAMPAIGN_TITLE, RESPONSE_TYPE, RESPONSE_TEXT, SUBMITTER_NAME, SUBMITTED_DATE, VERIFICATION_URL, REPORT_URL, ORGANIZATION_NAME, TIMESTAMP

"},{"location":"v2/database/seeding/#shift-signup-confirmation","title":"Shift Signup Confirmation","text":"

Key: shift-signup-confirmation Category: MAP Variables: ORGANIZATION_NAME, USER_NAME, USER_EMAIL, SHIFT_TITLE, SHIFT_DATE, SHIFT_TIME, SHIFT_LOCATION, IS_NEW_USER, TEMP_PASSWORD, LOGIN_URL

"},{"location":"v2/database/seeding/#shift-details-reminder","title":"Shift Details Reminder","text":"

Key: shift-details Category: MAP Variables: ORGANIZATION_NAME, USER_NAME, SHIFT_TITLE, SHIFT_DATE, SHIFT_START_TIME, SHIFT_END_TIME, SHIFT_LOCATION, SHIFT_DESCRIPTION, CURRENT_VOLUNTEERS, MAX_VOLUNTEERS, SHIFT_STATUS

"},{"location":"v2/database/seeding/#seed-script-structure","title":"Seed Script Structure","text":""},{"location":"v2/database/seeding/#main-function","title":"Main Function","text":"
async function main() {\n  console.log('Seeding database...');\n\n  // 1. Create admin user\n  const admin = await createAdminUser();\n\n  // 2. Create map settings\n  await createMapSettings();\n\n  // 3. Create page blocks\n  await createPageBlocks();\n\n  // 4. Seed email templates\n  await seedEmailTemplates(admin);\n\n  console.log('Seed complete.');\n}\n
"},{"location":"v2/database/seeding/#upsert-pattern","title":"Upsert Pattern","text":"

All seed operations use upsert to be idempotent:

await prisma.pageBlock.upsert({\n  where: { id: block.id },\n  update: {},  // Don't update if exists\n  create: block,  // Create if doesn't exist\n});\n

Benefits: - Safe to run multiple times - Won't duplicate data - Won't overwrite user changes (empty update clause)

"},{"location":"v2/database/seeding/#error-handling","title":"Error Handling","text":"
main()\n  .catch((e) => {\n    console.error('Seed error:', e);\n    process.exit(1);\n  })\n  .finally(async () => {\n    await prisma.$disconnect();\n  });\n
"},{"location":"v2/database/seeding/#customizing-seed-data","title":"Customizing Seed Data","text":""},{"location":"v2/database/seeding/#change-admin-credentials","title":"Change Admin Credentials","text":"

Edit api/prisma/seed.ts:

const hashedPassword = await bcrypt.hash('YourSecurePassword123!', 10);\n\nconst admin = await prisma.user.upsert({\n  where: { email: 'your-email@example.com' },  // Change email\n  update: {\n    password: hashedPassword,\n    emailVerified: true,\n    status: 'ACTIVE',\n  },\n  create: {\n    email: 'your-email@example.com',  // Change email\n    password: hashedPassword,\n    name: 'Your Name',  // Change name\n    role: UserRole.SUPER_ADMIN,\n    emailVerified: true,\n  },\n});\n

"},{"location":"v2/database/seeding/#change-map-default-location","title":"Change Map Default Location","text":"

Edit api/prisma/seed.ts:

await prisma.mapSettings.upsert({\n  where: { id: 'default' },\n  update: {},\n  create: {\n    id: 'default',\n    latitude: 51.0447,    // Calgary, AB\n    longitude: -114.0719,\n    zoom: 11,\n    walkSheetTitle: 'Calgary Canvass Walk Sheet',\n    walkSheetSubtitle: 'District Canvassing',\n    walkSheetFooter: 'Thank you for volunteering!',\n  },\n});\n

"},{"location":"v2/database/seeding/#add-custom-page-blocks","title":"Add Custom Page Blocks","text":"
const customBlocks = [\n  {\n    id: 'custom-video',\n    type: 'video',\n    label: 'Video Embed',\n    category: 'Media',\n    sortOrder: 7,\n    schema: {\n      videoUrl: { type: 'string', label: 'Video URL' },\n      caption: { type: 'string', label: 'Caption' },\n    },\n    defaults: {\n      videoUrl: 'https://www.youtube.com/embed/...',\n      caption: 'Watch our video',\n    },\n  },\n];\n\nfor (const block of customBlocks) {\n  await prisma.pageBlock.upsert({\n    where: { id: block.id },\n    update: {},\n    create: block,\n  });\n}\n
"},{"location":"v2/database/seeding/#verifying-seed-data","title":"Verifying Seed Data","text":""},{"location":"v2/database/seeding/#check-admin-user","title":"Check Admin User","text":"
docker compose exec api npx prisma studio\n# Navigate to users table, filter by role = \"SUPER_ADMIN\"\n
"},{"location":"v2/database/seeding/#check-map-settings","title":"Check Map Settings","text":"
docker compose exec v2-postgres psql -U changemaker_v2 changemaker_v2 -c \"SELECT * FROM map_settings;\"\n
"},{"location":"v2/database/seeding/#check-page-blocks","title":"Check Page Blocks","text":"
docker compose exec v2-postgres psql -U changemaker_v2 changemaker_v2 -c \"SELECT id, type, label FROM page_blocks ORDER BY sort_order;\"\n
"},{"location":"v2/database/seeding/#check-email-templates","title":"Check Email Templates","text":"
docker compose exec v2-postgres psql -U changemaker_v2 changemaker_v2 -c \"SELECT key, name, category FROM email_templates;\"\n
"},{"location":"v2/database/seeding/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/database/seeding/#error-unique-constraint-failed-on-email","title":"Error: \"Unique constraint failed on email\"","text":"

Cause: Admin user already exists Solution: Seed uses upsert, so this shouldn't happen. Check seed script for typos.

"},{"location":"v2/database/seeding/#error-template-files-not-found","title":"Error: \"Template files not found\"","text":"

Cause: Email template .html/.txt files missing Solution: Ensure api/src/templates/email/ directory contains: - campaign-email.html - campaign-email.txt - response-verification.html - response-verification.txt - shift-signup-confirmation.html - shift-signup-confirmation.txt - shift-details.html - shift-details.txt

"},{"location":"v2/database/seeding/#error-cannot-find-module-bcryptjs","title":"Error: \"Cannot find module 'bcryptjs'\"","text":"

Cause: Dependencies not installed Solution:

cd api && npm install\n

"},{"location":"v2/database/seeding/#seed-doesnt-run-after-migration","title":"Seed doesn't run after migration","text":"

Cause: package.json missing prisma.seed config Solution: Add to api/package.json:

{\n  \"prisma\": {\n    \"seed\": \"ts-node prisma/seed.ts\"\n  }\n}\n

"},{"location":"v2/database/seeding/#production-seeding","title":"Production Seeding","text":""},{"location":"v2/database/seeding/#initial-deployment","title":"Initial Deployment","text":"
# 1. Deploy migrations\ndocker compose exec api npx prisma migrate deploy\n\n# 2. Run seed\ndocker compose exec api npx prisma db seed\n\n# 3. Change admin password immediately\n# Login at https://app.cmlite.org with admin@cmlite.org / ChangeMe2025!\n# Navigate to /app/profile, update password\n
"},{"location":"v2/database/seeding/#subsequent-deployments","title":"Subsequent Deployments","text":"

Don't re-run seed unless adding new seed data (new page blocks, email templates, etc.). Existing seed data uses upsert with empty update clause, so it won't overwrite user changes.

"},{"location":"v2/database/seeding/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/database/models/","title":"Database Models","text":"

Changemaker Lite V2 uses a comprehensive PostgreSQL database schema with 30+ models across authentication, campaigns, locations, media, and content management. The schema is managed via Prisma ORM (main API) and Drizzle ORM (media API).

"},{"location":"v2/database/models/#model-organization","title":"Model Organization","text":"

Models are organized by feature area:

"},{"location":"v2/database/models/#authentication-users","title":"Authentication & Users","text":"

Core authentication and user management:

"},{"location":"v2/database/models/#influence-module","title":"Influence Module","text":"

Advocacy campaign models:

"},{"location":"v2/database/models/#map-module","title":"Map Module","text":"

Location and geographic models:

"},{"location":"v2/database/models/#canvassing","title":"Canvassing","text":"

Door-to-door canvassing models:

"},{"location":"v2/database/models/#content-management","title":"Content Management","text":"

Landing pages and content:

"},{"location":"v2/database/models/#email-templates","title":"Email Templates","text":"

Email template system:

"},{"location":"v2/database/models/#media","title":"Media","text":"

Video library (Drizzle ORM):

"},{"location":"v2/database/models/#settings","title":"Settings","text":"

Global configuration:

"},{"location":"v2/database/models/#orm-architecture","title":"ORM Architecture","text":""},{"location":"v2/database/models/#prisma-main-api","title":"Prisma (Main API)","text":"

Used for 95% of models:

"},{"location":"v2/database/models/#drizzle-media-api","title":"Drizzle (Media API)","text":"

Used for media models only:

"},{"location":"v2/database/models/#common-patterns","title":"Common Patterns","text":""},{"location":"v2/database/models/#timestamps","title":"Timestamps","text":"

Most models include:

createdAt DateTime @default(now())\nupdatedAt DateTime @updatedAt\n
"},{"location":"v2/database/models/#foreign-keys","title":"Foreign Keys","text":"

Relations use explicit foreign key fields:

model Campaign {\n  id              Int     @id @default(autoincrement())\n  createdByUserId Int\n  createdBy       User    @relation(fields: [createdByUserId], references: [id])\n}\n
"},{"location":"v2/database/models/#json-fields","title":"JSON Fields","text":"

Flexible data stored as JSON:

model Campaign {\n  emailTemplate Json?\n  settings      Json?\n}\n

TypeScript types:

import { Prisma } from '@prisma/client';\n\nconst template: Prisma.InputJsonValue = {\n  subject: 'Email subject',\n  body: 'Email body',\n};\n
"},{"location":"v2/database/models/#enums","title":"Enums","text":"

Type-safe enumerations:

enum Role {\n  SUPER_ADMIN\n  INFLUENCE_ADMIN\n  MAP_ADMIN\n  USER\n  TEMP\n}\n\nenum VisitOutcome {\n  SUCCESS\n  NOT_HOME\n  MOVED\n  REFUSED\n  WRONG_ADDRESS\n  INACCESSIBLE\n  OTHER\n}\n
"},{"location":"v2/database/models/#model-count-by-category","title":"Model Count by Category","text":"Category Models ORM Authentication 3 Prisma Influence 4 Prisma Map 4 Prisma Canvassing 3 Prisma Content 2 Prisma Email Templates 2 Prisma Media 4 Drizzle Settings 2 Prisma Total 24 Mixed"},{"location":"v2/database/models/#database-operations","title":"Database Operations","text":""},{"location":"v2/database/models/#migrations-prisma","title":"Migrations (Prisma)","text":"
# Create migration\ncd api && npx prisma migrate dev --name add_field\n\n# Deploy migrations\ncd api && npx prisma migrate deploy\n\n# Reset database (dev only)\ncd api && npx prisma migrate reset\n
"},{"location":"v2/database/models/#schema-push-drizzle","title":"Schema Push (Drizzle)","text":"
# Push schema changes (media API)\ncd api && npx drizzle-kit push\n
"},{"location":"v2/database/models/#database-browser","title":"Database Browser","text":"

View data via:

"},{"location":"v2/database/models/#indexes","title":"Indexes","text":"

Key indexes for performance:

model Location {\n  @@index([cutId])\n  @@index([lastVisitedAt])\n}\n\nmodel Campaign {\n  @@index([published])\n  @@index([createdByUserId])\n}\n\nmodel CanvassSession {\n  @@index([userId])\n  @@index([status])\n}\n
"},{"location":"v2/database/models/#constraints","title":"Constraints","text":""},{"location":"v2/database/models/#unique-constraints","title":"Unique Constraints","text":"
model User {\n  email String @unique\n}\n\nmodel Page {\n  slug String @unique\n}\n\nmodel Cut {\n  name String @unique\n}\n
"},{"location":"v2/database/models/#check-constraints","title":"Check Constraints","text":"

Enforced at application level:

"},{"location":"v2/database/models/#relations","title":"Relations","text":""},{"location":"v2/database/models/#one-to-many","title":"One-to-Many","text":"
model User {\n  id        Int        @id @default(autoincrement())\n  campaigns Campaign[]\n}\n\nmodel Campaign {\n  id              Int  @id @default(autoincrement())\n  createdByUserId Int\n  createdBy       User @relation(fields: [createdByUserId], references: [id])\n}\n
"},{"location":"v2/database/models/#many-to-many","title":"Many-to-Many","text":"

Via junction tables:

model Shift {\n  id      Int            @id @default(autoincrement())\n  signups ShiftSignup[]\n}\n\nmodel User {\n  id      Int            @id @default(autoincrement())\n  signups ShiftSignup[]\n}\n\nmodel ShiftSignup {\n  id      Int   @id @default(autoincrement())\n  shiftId Int\n  userId  Int\n  shift   Shift @relation(fields: [shiftId], references: [id])\n  user    User  @relation(fields: [userId], references: [id])\n\n  @@unique([shiftId, userId])\n}\n
"},{"location":"v2/database/models/#seeding","title":"Seeding","text":"

Initial data in api/prisma/seed.ts:

# Run seed\ncd api && npx prisma db seed\n
"},{"location":"v2/database/models/#data-types","title":"Data Types","text":""},{"location":"v2/database/models/#common-types","title":"Common Types","text":""},{"location":"v2/database/models/#spatial-data","title":"Spatial Data","text":"

GeoJSON stored as JSON:

model Cut {\n  geometry Json  // GeoJSON Polygon\n}\n

Coordinates as separate fields:

model Location {\n  latitude  Float\n  longitude Float\n}\n
"},{"location":"v2/database/models/#database-configuration","title":"Database Configuration","text":""},{"location":"v2/database/models/#connection-string","title":"Connection String","text":"
DATABASE_URL=\"postgresql://user:password@localhost:5432/changemaker_v2?schema=public\"\n
"},{"location":"v2/database/models/#connection-pool","title":"Connection Pool","text":"

Prisma connection pool:

// api/src/server.ts\nconst prisma = new PrismaClient({\n  log: ['error', 'warn'],\n});\n
"},{"location":"v2/database/models/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/database/models/auth/","title":"Auth & Users Models","text":""},{"location":"v2/database/models/auth/#overview","title":"Overview","text":"

The Auth & Users module provides JWT-based authentication with role-based access control (RBAC), temporary user support for public shift signups, and refresh token rotation for enhanced security.

Models: - User \u2014 User accounts with roles and permissions - RefreshToken \u2014 JWT refresh token storage with expiration

Key Features: - bcrypt password hashing (12+ character policy enforced at schema level) - JWT access tokens (15min) + refresh tokens (7 days) - Refresh token rotation with atomic transactions - Role hierarchy: SUPER_ADMIN > INFLUENCE_ADMIN > MAP_ADMIN > USER > TEMP - Temporary user support with auto-expiration - Email verification workflow - User enumeration prevention (401 not 404)

"},{"location":"v2/database/models/auth/#models-summary","title":"Models Summary","text":"Model Table Description User users User accounts with RBAC, permissions, temp user support RefreshToken refresh_tokens JWT refresh tokens with expiration tracking"},{"location":"v2/database/models/auth/#user-model","title":"User Model","text":""},{"location":"v2/database/models/auth/#purpose","title":"Purpose","text":"

The User model represents all system users, from super admins to temporary volunteers created via public shift signup. It supports role-based access control, granular permissions, temporary user expiration, and comprehensive audit tracking via 33 relation fields.

"},{"location":"v2/database/models/auth/#fields","title":"Fields","text":"Field Type Required Default Description Identity id String \u2713 cuid() Primary key email String \u2713 \u2014 Unique email address (lowercase) password String \u2713 \u2014 bcrypt hashed (12+ chars, 1 uppercase, 1 lowercase, 1 digit) name String \u2717 null User display name phone String \u2717 null Phone number Authorization role UserRole \u2713 USER User role (see enum below) status UserStatus \u2713 ACTIVE Account status (see enum below) permissions Json \u2717 null Granular per-app permissions object User Lifecycle createdVia UserCreatedVia \u2713 STANDARD Creation source (see enum below) expiresAt DateTime \u2717 null Expiration date for TEMP users expireDays Int \u2717 null Days until expiration (for TEMP users) lastLoginAt DateTime \u2717 null Last login timestamp emailVerified Boolean \u2713 false Email verification status Audit createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp"},{"location":"v2/database/models/auth/#enums","title":"Enums","text":""},{"location":"v2/database/models/auth/#userrole","title":"UserRole","text":"

Role hierarchy (descending):

enum UserRole {\n  SUPER_ADMIN       // Full system access, can manage all users\n  INFLUENCE_ADMIN   // Manage influence module (campaigns, responses)\n  MAP_ADMIN         // Manage map module (locations, shifts, cuts)\n  USER              // Standard volunteer with assigned permissions\n  TEMP              // Temporary user (auto-expires, restricted access)\n}\n

Role Capabilities: - SUPER_ADMIN: Full access to all modules, user management, settings - INFLUENCE_ADMIN: Campaign CRUD, response moderation, email queue admin - MAP_ADMIN: Location CRUD, shift management, cut creation, canvass oversight - USER: Public campaign actions, shift signup, canvass sessions (via assigned cut) - TEMP: Canvass sessions only (via shift signup), auto-expires after X days

"},{"location":"v2/database/models/auth/#userstatus","title":"UserStatus","text":"
enum UserStatus {\n  ACTIVE     // Normal active user\n  INACTIVE   // Manually deactivated (login blocked)\n  SUSPENDED  // Temporarily suspended (login blocked)\n  EXPIRED    // Auto-expired temp user (login blocked)\n}\n
"},{"location":"v2/database/models/auth/#usercreatedvia","title":"UserCreatedVia","text":"
enum UserCreatedVia {\n  ADMIN                 // Created by admin in user management\n  PUBLIC_SHIFT_SIGNUP   // Auto-created via public shift signup\n  STANDARD              // Self-registered (if enabled)\n}\n
"},{"location":"v2/database/models/auth/#relations-33-total","title":"Relations (33 total)","text":"

Authentication: - refreshTokens \u2192 RefreshToken[] (onDelete: Cascade)

Influence Module (6): - campaignsCreated \u2192 Campaign[] (creator, onDelete: SetNull) - campaignEmails \u2192 CampaignEmail[] (sender, onDelete: SetNull) - responses \u2192 RepresentativeResponse[] (submitter, onDelete: SetNull) - responseUpvotes \u2192 ResponseUpvote[] (onDelete: SetNull)

Map Module (8): - locationsCreated \u2192 Location[] (creator, onDelete: SetNull) - locationsUpdated \u2192 Location[] (updater, onDelete: SetNull) - addressesCreated \u2192 Address[] (creator, onDelete: SetNull) - addressesUpdated \u2192 Address[] (updater, onDelete: SetNull) - locationEdits \u2192 LocationHistory[] (editor, onDelete: SetNull) - cutsCreated \u2192 Cut[] (creator, onDelete: SetNull) - shiftSignups \u2192 ShiftSignup[] (onDelete: SetNull)

Canvassing Module (4): - canvassVisits \u2192 CanvassVisit[] (visitor, onDelete: Cascade) - canvassSessions \u2192 CanvassSession[] (onDelete: Cascade) - trackingSessions \u2192 TrackingSession[] (onDelete: Cascade)

Email Templates Module (4): - templatesCreated \u2192 EmailTemplate[] (creator) - templatesUpdated \u2192 EmailTemplate[] (updater) - templateVersionsCreated \u2192 EmailTemplateVersion[] - templateTestsSent \u2192 EmailTemplateTestLog[]

"},{"location":"v2/database/models/auth/#indexes","title":"Indexes","text":""},{"location":"v2/database/models/auth/#constraints","title":"Constraints","text":""},{"location":"v2/database/models/auth/#refreshtoken-model","title":"RefreshToken Model","text":""},{"location":"v2/database/models/auth/#purpose_1","title":"Purpose","text":"

The RefreshToken model stores JWT refresh tokens for token rotation. When a user logs in, both an access token (15min expiry, stored client-side) and a refresh token (7 day expiry, stored in DB) are issued. When the access token expires, the client uses the refresh token to obtain a new access token. For security, refresh tokens are rotated on each refresh (old token deleted, new token issued) using atomic Prisma transactions.

"},{"location":"v2/database/models/auth/#fields_1","title":"Fields","text":"Field Type Required Default Description id String \u2713 cuid() Primary key token String \u2713 \u2014 JWT refresh token string (unique, 512 chars) userId String \u2713 \u2014 Foreign key to User expiresAt DateTime \u2713 \u2014 Token expiration timestamp (7 days from issue) createdAt DateTime \u2713 now() Token creation timestamp"},{"location":"v2/database/models/auth/#relations","title":"Relations","text":""},{"location":"v2/database/models/auth/#indexes_1","title":"Indexes","text":""},{"location":"v2/database/models/auth/#constraints_1","title":"Constraints","text":""},{"location":"v2/database/models/auth/#relationships-diagram","title":"Relationships Diagram","text":"
erDiagram\n    User ||--o{ RefreshToken : has\n    User ||--o{ Campaign : creates\n    User ||--o{ CampaignEmail : sends\n    User ||--o{ RepresentativeResponse : submits\n    User ||--o{ ResponseUpvote : upvotes\n    User ||--o{ ShiftSignup : \"signs up for\"\n    User ||--o{ Location : creates\n    User ||--o{ Location : updates\n    User ||--o{ Address : \"creates (addresses)\"\n    User ||--o{ Address : \"updates (addresses)\"\n    User ||--o{ LocationHistory : edits\n    User ||--o{ Cut : \"creates (cuts)\"\n    User ||--o{ CanvassVisit : visits\n    User ||--o{ CanvassSession : \"has (sessions)\"\n    User ||--o{ TrackingSession : \"tracks (gps)\"\n    User ||--o{ EmailTemplate : \"creates (templates)\"\n    User ||--o{ EmailTemplate : \"updates (templates)\"\n    User ||--o{ EmailTemplateVersion : \"versions (templates)\"\n    User ||--o{ EmailTemplateTestLog : \"tests (templates)\"\n\n    User {\n        String id PK\n        String email UK \"unique, lowercase\"\n        String password \"bcrypt hashed\"\n        String name\n        String phone\n        UserRole role \"SUPER_ADMIN | INFLUENCE_ADMIN | MAP_ADMIN | USER | TEMP\"\n        UserStatus status \"ACTIVE | INACTIVE | SUSPENDED | EXPIRED\"\n        Json permissions \"granular per-app\"\n        UserCreatedVia createdVia\n        DateTime expiresAt \"TEMP user expiration\"\n        Int expireDays\n        DateTime lastLoginAt\n        Boolean emailVerified\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    RefreshToken {\n        String id PK\n        String token UK\n        String userId FK\n        DateTime expiresAt\n        DateTime createdAt\n    }
"},{"location":"v2/database/models/auth/#common-queries","title":"Common Queries","text":""},{"location":"v2/database/models/auth/#create-user-admin","title":"Create User (Admin)","text":"
const user = await prisma.user.create({\n  data: {\n    email: 'volunteer@example.com',\n    password: await bcrypt.hash('SecurePass123!', 10),\n    name: 'Jane Volunteer',\n    phone: '555-0100',\n    role: UserRole.USER,\n    status: UserStatus.ACTIVE,\n    createdVia: UserCreatedVia.ADMIN,\n    emailVerified: true,\n  },\n});\n
"},{"location":"v2/database/models/auth/#create-temp-user-public-shift-signup","title":"Create Temp User (Public Shift Signup)","text":"
const tempUser = await prisma.user.create({\n  data: {\n    email: 'temp@example.com',\n    password: await bcrypt.hash(randomPassword, 10), // Generated password\n    name: 'Temp Volunteer',\n    role: UserRole.TEMP,\n    status: UserStatus.ACTIVE,\n    createdVia: UserCreatedVia.PUBLIC_SHIFT_SIGNUP,\n    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days\n    expireDays: 30,\n  },\n});\n
"},{"location":"v2/database/models/auth/#find-user-with-relations","title":"Find User with Relations","text":"
const user = await prisma.user.findUnique({\n  where: { email: 'admin@example.com' },\n  include: {\n    campaignsCreated: { take: 5, orderBy: { createdAt: 'desc' } },\n    canvassSessions: { take: 10, orderBy: { startedAt: 'desc' } },\n    shiftSignups: { include: { shift: true } },\n  },\n});\n
"},{"location":"v2/database/models/auth/#update-last-login","title":"Update Last Login","text":"
await prisma.user.update({\n  where: { id: userId },\n  data: { lastLoginAt: new Date() },\n});\n
"},{"location":"v2/database/models/auth/#store-refresh-token","title":"Store Refresh Token","text":"
const refreshToken = await prisma.refreshToken.create({\n  data: {\n    token: jwtRefreshToken,\n    userId: user.id,\n    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days\n  },\n});\n
"},{"location":"v2/database/models/auth/#refresh-token-rotation-atomic","title":"Refresh Token Rotation (Atomic)","text":"
const newTokens = await prisma.$transaction(async (tx) => {\n  // 1. Verify old token exists and is valid\n  const oldToken = await tx.refreshToken.findUnique({\n    where: { token: oldRefreshToken },\n    include: { user: true },\n  });\n\n  if (!oldToken || oldToken.expiresAt < new Date()) {\n    throw new Error('Invalid or expired refresh token');\n  }\n\n  // 2. Delete old token\n  await tx.refreshToken.delete({\n    where: { id: oldToken.id },\n  });\n\n  // 3. Generate new access + refresh tokens\n  const newAccessToken = jwt.sign({ userId: oldToken.userId }, ACCESS_SECRET, { expiresIn: '15m' });\n  const newRefreshToken = jwt.sign({ userId: oldToken.userId }, REFRESH_SECRET, { expiresIn: '7d' });\n\n  // 4. Store new refresh token\n  await tx.refreshToken.create({\n    data: {\n      token: newRefreshToken,\n      userId: oldToken.userId,\n      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),\n    },\n  });\n\n  return { accessToken: newAccessToken, refreshToken: newRefreshToken };\n});\n
"},{"location":"v2/database/models/auth/#expire-temp-users-cron","title":"Expire Temp Users (Cron)","text":"
await prisma.user.updateMany({\n  where: {\n    role: UserRole.TEMP,\n    expiresAt: { lt: new Date() },\n    status: { not: UserStatus.EXPIRED },\n  },\n  data: {\n    status: UserStatus.EXPIRED,\n  },\n});\n
"},{"location":"v2/database/models/auth/#clean-expired-refresh-tokens-cron","title":"Clean Expired Refresh Tokens (Cron)","text":"
await prisma.refreshToken.deleteMany({\n  where: {\n    expiresAt: { lt: new Date() },\n  },\n});\n
"},{"location":"v2/database/models/auth/#data-flow","title":"Data Flow","text":""},{"location":"v2/database/models/auth/#user-registration-flow","title":"User Registration Flow","text":"
sequenceDiagram\n    participant Client\n    participant API\n    participant Prisma\n    participant bcrypt\n    participant JWT\n\n    Client->>API: POST /api/auth/register (email, password, name)\n    API->>bcrypt: hash(password)\n    bcrypt-->>API: hashedPassword\n    API->>Prisma: user.create({ email, password: hashed, role: USER })\n    Prisma-->>API: user\n    API->>JWT: sign(accessToken, { userId, email, role })\n    API->>JWT: sign(refreshToken, { userId })\n    JWT-->>API: tokens\n    API->>Prisma: refreshToken.create({ token, userId, expiresAt })\n    Prisma-->>API: refreshToken\n    API-->>Client: { user, accessToken, refreshToken }
"},{"location":"v2/database/models/auth/#login-flow","title":"Login Flow","text":"
sequenceDiagram\n    participant Client\n    participant API\n    participant Prisma\n    participant bcrypt\n    participant JWT\n\n    Client->>API: POST /api/auth/login (email, password)\n    API->>Prisma: user.findUnique({ where: { email } })\n    Prisma-->>API: user | null\n    API->>bcrypt: compare(password, user.password)\n    bcrypt-->>API: isValid\n    alt Invalid credentials\n        API-->>Client: 401 Unauthorized\n    else Valid credentials\n        API->>Prisma: user.update({ lastLoginAt: now() })\n        API->>JWT: sign(accessToken, { userId, email, role })\n        API->>JWT: sign(refreshToken, { userId })\n        JWT-->>API: tokens\n        API->>Prisma: refreshToken.create({ token, userId, expiresAt })\n        Prisma-->>API: refreshToken\n        API-->>Client: { user, accessToken, refreshToken }\n    end
"},{"location":"v2/database/models/auth/#token-refresh-flow","title":"Token Refresh Flow","text":"
sequenceDiagram\n    participant Client\n    participant API\n    participant Prisma\n    participant JWT\n\n    Client->>API: POST /api/auth/refresh (refreshToken)\n    API->>JWT: verify(refreshToken)\n    JWT-->>API: payload | error\n    alt Invalid token\n        API-->>Client: 401 Unauthorized\n    else Valid token\n        API->>Prisma: $transaction start\n        API->>Prisma: refreshToken.findUnique({ where: { token } })\n        Prisma-->>API: oldToken | null\n        alt Token not found or expired\n            API->>Prisma: $transaction rollback\n            API-->>Client: 401 Unauthorized\n        else Token valid\n            API->>Prisma: refreshToken.delete({ where: { id: oldToken.id } })\n            API->>JWT: sign(newAccessToken, { userId, email, role })\n            API->>JWT: sign(newRefreshToken, { userId })\n            JWT-->>API: newTokens\n            API->>Prisma: refreshToken.create({ token: newRefreshToken, userId, expiresAt })\n            API->>Prisma: $transaction commit\n            Prisma-->>API: success\n            API-->>Client: { accessToken, refreshToken }\n        end\n    end
"},{"location":"v2/database/models/auth/#performance-notes","title":"Performance Notes","text":""},{"location":"v2/database/models/auth/#index-usage","title":"Index Usage","text":""},{"location":"v2/database/models/auth/#query-optimization","title":"Query Optimization","text":""},{"location":"v2/database/models/auth/#n1-prevention","title":"N+1 Prevention","text":"
// \u274c N+1 query (loads campaigns one-by-one)\nconst users = await prisma.user.findMany();\nfor (const user of users) {\n  const campaigns = await prisma.campaign.findMany({ where: { createdByUserId: user.id } });\n}\n\n// \u2705 Single query with include\nconst users = await prisma.user.findMany({\n  include: {\n    campaignsCreated: true,\n  },\n});\n
"},{"location":"v2/database/models/auth/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/database/models/auth/#password-policy","title":"Password Policy","text":"

Enforced at API schema level (auth.schemas.ts):

password: z.string()\n  .min(12, 'Password must be at least 12 characters')\n  .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')\n  .regex(/[a-z]/, 'Password must contain at least one lowercase letter')\n  .regex(/[0-9]/, 'Password must contain at least one digit')\n

"},{"location":"v2/database/models/auth/#user-enumeration-prevention","title":"User Enumeration Prevention","text":""},{"location":"v2/database/models/auth/#refresh-token-security","title":"Refresh Token Security","text":""},{"location":"v2/database/models/auth/#role-based-access-control","title":"Role-Based Access Control","text":"

Middleware enforces role requirements:

// Requires SUPER_ADMIN or MAP_ADMIN\nrouter.get('/api/locations', requireRole(UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN), ...)\n\n// Requires any non-TEMP user\nrouter.get('/api/campaigns', requireNonTemp, ...)\n\n// Requires any authenticated user\nrouter.get('/api/profile', authenticate, ...)\n

"},{"location":"v2/database/models/auth/#temp-user-restrictions","title":"TEMP User Restrictions","text":""},{"location":"v2/database/models/auth/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/database/models/auth/#email-already-exists-on-registration","title":"\"Email already exists\" on registration","text":"

Cause: Email uniqueness constraint violated Solution: Check for existing user: prisma.user.findUnique({ where: { email } })

"},{"location":"v2/database/models/auth/#invalid-refresh-token-on-refresh","title":"\"Invalid refresh token\" on refresh","text":"

Cause: Token already used (rotation), expired, or manually deleted Solution: User must re-login to obtain new token pair

"},{"location":"v2/database/models/auth/#password-does-not-meet-policy-on-update","title":"\"Password does not meet policy\" on update","text":"

Cause: Password validation regex mismatch Solution: Ensure new password has 12+ chars, 1 uppercase, 1 lowercase, 1 digit

"},{"location":"v2/database/models/auth/#temp-user-cannot-access-route","title":"TEMP user cannot access route","text":"

Cause: Route uses requireNonTemp middleware Solution: Upgrade user to USER role via admin panel

"},{"location":"v2/database/models/auth/#circular-dependency-auth-store-api-client","title":"Circular dependency: auth store \u2194 api client","text":"

Cause: Both modules import each other Solution: Use callback registration pattern (see admin/src/lib/api.ts + admin/src/stores/auth.store.ts)

"},{"location":"v2/database/models/auth/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/database/models/canvass/","title":"Canvassing Models","text":""},{"location":"v2/database/models/canvass/#overview","title":"Overview","text":"

The Canvassing module provides GPS-tracked volunteer canvassing with session management, visit recording, walking route algorithms, and automatic session abandonment.

Models (4): - CanvassSession \u2014 Session lifecycle (ACTIVE \u2192 COMPLETED/ABANDONED) - CanvassVisit \u2014 Visit recording with 7 outcome types - TrackingSession \u2014 GPS tracking integration - TrackPoint \u2014 GPS breadcrumb trail

Key Features: - Session lifecycle management (ACTIVE \u2192 COMPLETED/ABANDONED) - 7 visit outcomes (NOT_HOME, REFUSED, MOVED, ALREADY_VOTED, SPOKE_WITH, LEFT_LITERATURE, COME_BACK_LATER) - Walking route algorithm (nearest-neighbor with haversine distance) - GPS breadcrumb trail with event markers - Support level tracking (1-4) - Sign request tracking - Session abandonment (12h timeout, auto-ABANDONED status) - Distance calculation (meters)

See Schema Reference for complete field listings.

"},{"location":"v2/database/models/canvass/#session-lifecycle","title":"Session Lifecycle","text":"
stateDiagram-v2\n    [*] --> ACTIVE : Start session\n    ACTIVE --> COMPLETED : End session (user action)\n    ACTIVE --> ABANDONED : 12h timeout (cron)\n    COMPLETED --> [*]\n    ABANDONED --> [*]

Status: CanvassSessionStatus - ACTIVE \u2014 Session in progress - COMPLETED \u2014 Session ended by user - ABANDONED \u2014 Session inactive > 12h (auto-expired by cron)

"},{"location":"v2/database/models/canvass/#visit-outcomes","title":"Visit Outcomes","text":"
enum VisitOutcome {\n  NOT_HOME          // No one home\n  REFUSED           // Refused to talk\n  MOVED             // Resident moved away\n  ALREADY_VOTED     // Already voted (early voting)\n  SPOKE_WITH        // Successful conversation\n  LEFT_LITERATURE   // Left campaign literature\n  COME_BACK_LATER   // Asked to come back later\n}\n

Support Level Mapping: - Outcome: SPOKE_WITH \u2192 Record support level (1-4) - Outcome: REFUSED \u2192 Support level defaults to null or 1 - Outcome: NOT_HOME \u2192 No support level

"},{"location":"v2/database/models/canvass/#walking-route-algorithm","title":"Walking Route Algorithm","text":"

Algorithm: Nearest-neighbor with haversine distance calculation

Steps: 1. Get all unvisited addresses in cut 2. Start from session start coordinates (or cut centroid) 3. Find nearest unvisited address (haversine distance) 4. Add to route, mark as visited 5. Repeat from new position until all addresses visited

Implementation: api/src/modules/map/canvass/walking-route.service.ts

function calculateWalkingRoute(\n  addresses: Address[],\n  startLat: number,\n  startLng: number,\n  visitedAddressIds: string[]\n): WalkingRoute {\n  const unvisited = addresses.filter(a => !visitedAddressIds.includes(a.id));\n  const route: Address[] = [];\n  let currentLat = startLat;\n  let currentLng = startLng;\n\n  while (unvisited.length > 0) {\n    // Find nearest unvisited address\n    const nearest = findNearestAddress(currentLat, currentLng, unvisited);\n    route.push(nearest);\n    currentLat = nearest.location.latitude;\n    currentLng = nearest.location.longitude;\n    unvisited.splice(unvisited.indexOf(nearest), 1);\n  }\n\n  return {\n    addresses: route,\n    totalDistanceM: calculateTotalDistance(route),\n  };\n}\n
"},{"location":"v2/database/models/canvass/#gps-tracking","title":"GPS Tracking","text":"

TrackingSession = One-to-one with CanvassSession - Stores total points, distance, last position - isActive flag for active tracking

TrackPoint = GPS breadcrumb - Latitude, longitude, accuracy - Event type markers (LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED)

Event Flow:

sequenceDiagram\n    participant Volunteer\n    participant API\n    participant GPS\n\n    Volunteer->>API: POST /api/canvass/sessions (start session)\n    API-->>Volunteer: sessionId\n    loop Every 30 seconds\n        GPS->>API: POST /api/tracking/:sessionId/points (lat, lng)\n        API-->>GPS: 200 OK\n    end\n    Volunteer->>API: POST /api/canvass/visits (record visit)\n    API->>GPS: POST /api/tracking/:sessionId/points (eventType: VISIT_RECORDED)\n    Volunteer->>API: POST /api/canvass/sessions/:id/end\n    API->>GPS: POST /api/tracking/:sessionId/points (eventType: SESSION_ENDED)\n    API-->>Volunteer: session (status: COMPLETED)

"},{"location":"v2/database/models/canvass/#session-abandonment","title":"Session Abandonment","text":"

Cron Job: Runs hourly via api/src/server.ts startup + interval

async function abandonStaleSessions() {\n  const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);\n\n  await prisma.canvassSession.updateMany({\n    where: {\n      status: CanvassSessionStatus.ACTIVE,\n      startedAt: { lt: twelveHoursAgo },\n    },\n    data: {\n      status: CanvassSessionStatus.ABANDONED,\n      endedAt: new Date(),\n    },\n  });\n}\n

Trigger Conditions: - Status = ACTIVE - StartedAt < 12 hours ago - No explicit end by user

"},{"location":"v2/database/models/canvass/#common-queries","title":"Common Queries","text":""},{"location":"v2/database/models/canvass/#start-canvass-session","title":"Start Canvass Session","text":"
const session = await prisma.canvassSession.create({\n  data: {\n    userId: user.id,\n    cutId: cut.id,\n    shiftId: shift?.id,\n    status: CanvassSessionStatus.ACTIVE,\n    startLatitude: startLat,\n    startLongitude: startLng,\n    trackingSession: {\n      create: {\n        userId: user.id,\n        isActive: true,\n      },\n    },\n  },\n});\n
"},{"location":"v2/database/models/canvass/#record-visit","title":"Record Visit","text":"
const visit = await prisma.canvassVisit.create({\n  data: {\n    addressId: address.id,\n    userId: user.id,\n    sessionId: session.id,\n    shiftId: shift?.id,\n    outcome: VisitOutcome.SPOKE_WITH,\n    supportLevel: SupportLevel.LEVEL_4,\n    signRequested: true,\n    signSize: 'Large',\n    notes: 'Very supportive, wants to volunteer',\n    durationSeconds: 180,\n  },\n});\n\n// Update address support level\nawait prisma.address.update({\n  where: { id: address.id },\n  data: {\n    supportLevel: SupportLevel.LEVEL_4,\n    sign: true,\n    signSize: 'Large',\n    notes: 'Very supportive, wants to volunteer',\n    updatedByUserId: user.id,\n  },\n});\n
"},{"location":"v2/database/models/canvass/#end-session","title":"End Session","text":"
await prisma.canvassSession.update({\n  where: { id: sessionId },\n  data: {\n    status: CanvassSessionStatus.COMPLETED,\n    endedAt: new Date(),\n    trackingSession: {\n      update: {\n        isActive: false,\n        endedAt: new Date(),\n      },\n    },\n  },\n});\n
"},{"location":"v2/database/models/canvass/#get-walking-route","title":"Get Walking Route","text":"
const session = await prisma.canvassSession.findUnique({\n  where: { id: sessionId },\n  include: {\n    visits: { include: { address: true } },\n  },\n});\n\nconst visitedAddressIds = session.visits.map(v => v.addressId);\n\nconst addresses = await prisma.address.findMany({\n  where: {\n    location: {\n      // Point-in-polygon check for cut\n      latitude: { gte: cutBounds.south, lte: cutBounds.north },\n      longitude: { gte: cutBounds.west, lte: cutBounds.east },\n    },\n  },\n  include: { location: true },\n});\n\nconst route = calculateWalkingRoute(\n  addresses,\n  session.startLatitude,\n  session.startLongitude,\n  visitedAddressIds\n);\n
"},{"location":"v2/database/models/canvass/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/database/models/email-templates/","title":"Email Template Models","text":""},{"location":"v2/database/models/email-templates/#overview","title":"Overview","text":"

The Email Template module provides a reusable template system with Handlebars-style variable interpolation, version history, and test email functionality.

Models (4): - EmailTemplate \u2014 Template master with categories - EmailTemplateVariable \u2014 Variable definitions - EmailTemplateVersion \u2014 Version history - EmailTemplateTestLog \u2014 Test email audit

Key Features: - Handlebars-style {{VAR}} interpolation - 3 categories: INFLUENCE, MAP, SYSTEM - System template protection (isSystem flag prevents deletion) - Version history with auto-increment version numbers - Conditional variables for {{#if}} blocks - Test email sending with sample data - HTML + plain text content

See Schema Reference for complete field listings.

"},{"location":"v2/database/models/email-templates/#template-categories","title":"Template Categories","text":"
enum EmailTemplateCategory {\n  INFLUENCE  // Campaign emails, response verification\n  MAP        // Shift confirmations, reminders\n  SYSTEM     // Password resets, welcome emails\n}\n
"},{"location":"v2/database/models/email-templates/#variable-interpolation","title":"Variable Interpolation","text":"

Syntax: Handlebars-style {{VARIABLE_NAME}}

Example Template:

<p>Hello {{USER_NAME}},</p>\n<p>Thank you for signing up for the shift:</p>\n<ul>\n  <li>Title: {{SHIFT_TITLE}}</li>\n  <li>Date: {{SHIFT_DATE}}</li>\n  <li>Time: {{SHIFT_TIME}}</li>\n</ul>\n{{#if IS_NEW_USER}}\n<p>Your temporary password is: {{TEMP_PASSWORD}}</p>\n{{/if}}\n

Variable Record:

{\n  key: 'USER_NAME',\n  label: 'User Name',\n  description: 'Name of the volunteer',\n  isRequired: true,\n  isConditional: false,\n  sampleValue: 'Jane Doe',\n  sortOrder: 0,\n}\n

"},{"location":"v2/database/models/email-templates/#version-history","title":"Version History","text":"

Auto-Increment Version Numbers:

const latestVersion = await prisma.emailTemplateVersion.findFirst({\n  where: { templateId },\n  orderBy: { versionNumber: 'desc' },\n});\n\nconst newVersion = await prisma.emailTemplateVersion.create({\n  data: {\n    templateId,\n    versionNumber: (latestVersion?.versionNumber || 0) + 1,\n    subjectLine,\n    htmlContent,\n    textContent,\n    changeNotes: 'Updated call-to-action wording',\n    createdByUserId: user.id,\n  },\n});\n

"},{"location":"v2/database/models/email-templates/#system-templates-4-seeded","title":"System Templates (4 seeded)","text":"

1. campaign-email (INFLUENCE) - Variables: CAMPAIGN_TITLE, MESSAGE, USER_NAME, USER_EMAIL, POSTAL_CODE, RECIPIENT_NAME, RECIPIENT_LEVEL, ORGANIZATION_NAME, TIMESTAMP - Used by: Campaign email sending

2. response-verification (INFLUENCE) - Variables: CAMPAIGN_TITLE, RESPONSE_TYPE, RESPONSE_TEXT, SUBMITTER_NAME, SUBMITTED_DATE, VERIFICATION_URL, REPORT_URL, ORGANIZATION_NAME, TIMESTAMP - Used by: Response wall submission verification

3. shift-signup-confirmation (MAP) - Variables: ORGANIZATION_NAME, USER_NAME, USER_EMAIL, SHIFT_TITLE, SHIFT_DATE, SHIFT_TIME, SHIFT_LOCATION, IS_NEW_USER, TEMP_PASSWORD, LOGIN_URL - Used by: Public shift signup

4. shift-details (MAP) - Variables: ORGANIZATION_NAME, USER_NAME, SHIFT_TITLE, SHIFT_DATE, SHIFT_START_TIME, SHIFT_END_TIME, SHIFT_LOCATION, SHIFT_DESCRIPTION, CURRENT_VOLUNTEERS, MAX_VOLUNTEERS, SHIFT_STATUS - Used by: Shift reminder emails

"},{"location":"v2/database/models/email-templates/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/database/models/influence/","title":"Influence Models","text":""},{"location":"v2/database/models/influence/#overview","title":"Overview","text":"

The Influence module provides advocacy campaign management with multi-government-level targeting, email/call tracking, response wall with moderation, and representative caching.

Models (10): - Campaign \u2014 Advocacy campaigns with 12 feature flags - Representative \u2014 Cached rep data from Represent API - CampaignEmail \u2014 Email tracking (SMTP vs MAILTO) - RepresentativeResponse \u2014 Response wall submissions - ResponseUpvote \u2014 Upvote tracking with deduplication - CustomRecipient \u2014 Custom email targets - PostalCodeCache \u2014 Postal code geocoding cache - EmailLog \u2014 Email audit trail - EmailVerification \u2014 Email verification tokens - Call \u2014 Phone call tracking

Key Features: - Multi-government-level targeting (Federal, Provincial, Municipal, School Board) - Dual email methods: SMTP (async BullMQ queue) + mailto: links - Response moderation workflow (PENDING \u2192 APPROVED/REJECTED) - Email verification for response wall submissions - Upvote deduplication (user ID + IP address) - Represent API integration for Canadian representatives - Postal code \u2192 representative lookup

See Schema Reference for complete field listings.

"},{"location":"v2/database/models/influence/#campaign-feature-flags-12-total","title":"Campaign Feature Flags (12 total)","text":"Flag Default Description allowSmtpEmail true Enable SMTP email sending via BullMQ allowMailtoLink true Enable mailto: links for client-side email collectUserInfo true Collect sender name/email/postal code showEmailCount true Display email sent count on public page showCallCount true Display call made count on public page allowEmailEditing false Allow users to edit email subject/body allowCustomRecipients false Enable custom recipient management showResponseWall false Enable public response wall highlightCampaign false Highlight on campaigns list page"},{"location":"v2/database/models/influence/#government-level-targeting","title":"Government Level Targeting","text":"
enum GovernmentLevel {\n  FEDERAL\n  PROVINCIAL\n  MUNICIPAL\n  SCHOOL_BOARD\n}\n

Campaigns can target multiple levels:

const campaign = await prisma.campaign.create({\n  data: {\n    title: 'Support Climate Action',\n    targetGovernmentLevels: [GovernmentLevel.FEDERAL, GovernmentLevel.PROVINCIAL],\n    // ...\n  },\n});\n

Representative lookup filters by targeted levels:

const reps = await representativeService.lookup(postalCode, campaign.targetGovernmentLevels);\n

"},{"location":"v2/database/models/influence/#email-methods","title":"Email Methods","text":""},{"location":"v2/database/models/influence/#smtp-async-queue","title":"SMTP (Async Queue)","text":""},{"location":"v2/database/models/influence/#mailto-client-side","title":"MAILTO (Client-Side)","text":""},{"location":"v2/database/models/influence/#response-moderation-workflow","title":"Response Moderation Workflow","text":"
stateDiagram-v2\n    [*] --> PENDING : Submit response\n    PENDING --> APPROVED : Admin approves\n    PENDING --> REJECTED : Admin rejects\n    APPROVED --> [*]\n    REJECTED --> [*]

Status: PENDING (default) \u2192 APPROVED | REJECTED

Admin moderation via /app/influence/responses: - Filter by status, campaign, date range - Bulk approve/reject - View submitter details - Screenshot attachments

"},{"location":"v2/database/models/influence/#upvote-deduplication","title":"Upvote Deduplication","text":"

Two unique constraints prevent duplicate upvotes:

model ResponseUpvote {\n  @@unique([responseId, userId])      // Logged-in users\n  @@unique([responseId, upvotedIp])  // Guest users\n}\n

Logic: - Logged-in user: Check [responseId, userId] - Guest user: Check [responseId, upvotedIp] - Database-level enforcement (no race conditions)

"},{"location":"v2/database/models/influence/#represent-api-integration","title":"Represent API Integration","text":"

Representative Cache: - Cached in representatives table - TTL: 30 days (check cachedAt field) - Re-fetched if cache miss or stale

Lookup Flow:

sequenceDiagram\n    participant Client\n    participant API\n    participant Cache\n    participant Represent\n\n    Client->>API: GET /api/representatives/lookup?postalCode=K1A0B1\n    API->>Cache: findMany({ where: { postalCode } })\n    alt Cache hit (cachedAt < 30 days ago)\n        Cache-->>API: representatives[]\n        API-->>Client: representatives[]\n    else Cache miss or stale\n        API->>Represent: GET /representatives/?point=K1A0B1\n        Represent-->>API: representatives[]\n        API->>Cache: upsert({ postalCode, ... })\n        API-->>Client: representatives[]\n    end

"},{"location":"v2/database/models/influence/#common-queries","title":"Common Queries","text":""},{"location":"v2/database/models/influence/#create-campaign","title":"Create Campaign","text":"
const campaign = await prisma.campaign.create({\n  data: {\n    slug: 'climate-action',\n    title: 'Support Climate Action Bill C-12',\n    emailSubject: 'Support Climate Action',\n    emailBody: 'I urge you to support...',\n    status: CampaignStatus.ACTIVE,\n    targetGovernmentLevels: [GovernmentLevel.FEDERAL],\n    allowSmtpEmail: true,\n    showResponseWall: true,\n    createdByUserId: user.id,\n  },\n});\n
"},{"location":"v2/database/models/influence/#queue-campaign-email-smtp","title":"Queue Campaign Email (SMTP)","text":"
await emailQueueService.addCampaignEmail({\n  campaignId: campaign.id,\n  recipientEmail: 'rep@example.com',\n  recipientName: 'Hon. Jane Smith',\n  subject: 'Support Climate Action',\n  message: 'I urge you to...',\n  userEmail: 'voter@example.com',\n  userName: 'John Voter',\n  userPostalCode: 'K1A0B1',\n});\n
"},{"location":"v2/database/models/influence/#submit-response","title":"Submit Response","text":"
const response = await prisma.representativeResponse.create({\n  data: {\n    campaignId: campaign.id,\n    campaignSlug: campaign.slug,\n    representativeName: 'Hon. Jane Smith',\n    representativeLevel: GovernmentLevel.FEDERAL,\n    responseType: ResponseType.EMAIL,\n    responseText: 'Thank you for your letter...',\n    submittedByUserId: user.id,\n    submittedByEmail: 'voter@example.com',\n    status: ResponseStatus.PENDING,\n  },\n});\n
"},{"location":"v2/database/models/influence/#upvote-response","title":"Upvote Response","text":"
await prisma.responseUpvote.create({\n  data: {\n    responseId: response.id,\n    userId: user?.id,  // Null for guests\n    userEmail: user?.email,\n    upvotedIp: req.ip,\n  },\n});\n\n// Increment upvote count (denormalized)\nawait prisma.representativeResponse.update({\n  where: { id: response.id },\n  data: { upvoteCount: { increment: 1 } },\n});\n
"},{"location":"v2/database/models/influence/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/database/models/map/","title":"Map Models","text":""},{"location":"v2/database/models/map/#overview","title":"Overview","text":"

The Map module provides building-level and unit-level location management with multi-provider geocoding, volunteer shift scheduling, GeoJSON polygon cuts for map filtering, and comprehensive audit trails.

Models (7): - Location \u2014 Building-level with lat/lng, NAR integration - Address \u2014 Unit-level with support levels - LocationHistory \u2014 Audit trail (7 action types) - Shift \u2014 Volunteer shifts with cut relation - ShiftSignup \u2014 Signup tracking - Cut \u2014 GeoJSON polygon overlays - MapSettings \u2014 Singleton configuration

Key Features: - Building vs unit architecture (1 Location \u2192 many Addresses) - Multi-provider geocoding (6 providers: Google, Mapbox, Nominatim, Photon, LocationIQ, ArcGIS) - NAR 2025 Canadian electoral data import - Spatial indexing (latitude/longitude composite index) - GeoJSON polygon storage for cuts - Walk sheet generation with QR codes - CSV import/export

See Schema Reference for complete field listings.

"},{"location":"v2/database/models/map/#building-vs-unit-architecture","title":"Building vs Unit Architecture","text":"

Location = Building-level data: - Single lat/lng coordinate - Street address (no unit number) - Building type (SINGLE_FAMILY, MULTI_UNIT, MIXED_USE, COMMERCIAL) - Total units count - Building notes (access codes, manager contact)

Address = Unit-level data: - Unit number (apartment #, suite #) - Occupant name/email/phone - Support level (1-4) - Sign request flag - Canvassing notes

Relationship: Location ||--o{ Address (one-to-many)

Example:

// 1 Location: 123 Main St (4-unit apartment building)\nconst location = {\n  address: '123 Main St',\n  latitude: 53.5461,\n  longitude: -113.4938,\n  buildingType: 'MULTI_UNIT',\n  totalUnits: 4,\n};\n\n// 4 Addresses: Units 101-104\nconst addresses = [\n  { locationId, unitNumber: '101', firstName: 'Alice', supportLevel: '4' },\n  { locationId, unitNumber: '102', firstName: 'Bob', supportLevel: '3' },\n  { locationId, unitNumber: '103', firstName: 'Carol', supportLevel: '2' },\n  { locationId, unitNumber: '104', firstName: 'Dave', supportLevel: '1' },\n];\n

"},{"location":"v2/database/models/map/#geocoding-providers","title":"Geocoding Providers","text":"
enum GeocodeProvider {\n  GOOGLE        // Google Maps Geocoding API\n  MAPBOX        // Mapbox Geocoding API\n  NOMINATIM     // OpenStreetMap Nominatim\n  PHOTON        // Photon (OSM-based)\n  LOCATIONIQ    // LocationIQ (OSM-based)\n  ARCGIS        // ArcGIS Geocoding Service\n  UNKNOWN       // Manually entered or unknown source\n}\n

Provider Priority: 1. Google (highest accuracy, paid) 2. Mapbox (high accuracy, paid) 3. ArcGIS (high accuracy, free tier) 4. Nominatim (medium accuracy, free) 5. Photon (medium accuracy, free) 6. LocationIQ (medium accuracy, free tier)

Confidence Score: 0-100 (stored in geocodeConfidence field)

"},{"location":"v2/database/models/map/#nar-2025-import","title":"NAR 2025 Import","text":"

NAR = National Address Register (Canadian electoral data)

Import Features: - Streams large CSV files (no memory limit) - Joins Location + Address files on LOC_GUID - Converts BG_X/BG_Y (EPSG:3347 Lambert projection) \u2192 lat/lng - Province selector (codes 10-62) - City/postal/cut filters - Residential-only toggle (buildingUse = 1)

New Location Fields: - postalCode \u2014 Canadian postal code - province \u2014 Province code (e.g., \"AB\") - federalDistrict \u2014 Federal electoral district - buildingUse \u2014 NAR BU_USE (1=Residential, 2=Partial, 3=Non-Residential, 4=Unknown) - locGuid \u2014 NAR LOC_GUID (unique)

New Address Fields: - addrGuid \u2014 NAR ADDR_GUID (unique)

"},{"location":"v2/database/models/map/#locationhistory-actions","title":"LocationHistory Actions","text":"
enum LocationHistoryAction {\n  CREATED          // Location created\n  UPDATED          // Location updated\n  GEOCODED         // Single location geocoded\n  BULK_GEOCODED    // Batch geocode operation\n  MOVED_ON_MAP     // Dragged on admin map\n  IMPORTED_CSV     // CSV import\n  IMPORTED_NAR     // NAR import\n}\n

Audit Fields: - field \u2014 Which field changed (e.g., \"latitude\") - oldValue \u2014 Previous value - newValue \u2014 New value - metadata \u2014 JSON with provider, confidence, etc.

"},{"location":"v2/database/models/map/#cut-geojson-storage","title":"Cut GeoJSON Storage","text":"

Cut stores GeoJSON polygon coordinates:

{\n  \"type\": \"Polygon\",\n  \"coordinates\": [\n    [\n      [-113.5, 53.5],\n      [-113.4, 53.5],\n      [-113.4, 53.6],\n      [-113.5, 53.6],\n      [-113.5, 53.5]\n    ]\n  ]\n}\n

Bounds: Calculated bounding box for quick filtering:

{\n  \"north\": 53.6,\n  \"south\": 53.5,\n  \"east\": -113.4,\n  \"west\": -113.5\n}\n

"},{"location":"v2/database/models/map/#shift-status-workflow","title":"Shift Status Workflow","text":"
stateDiagram-v2\n    [*] --> OPEN : Create shift\n    OPEN --> FULL : currentVolunteers >= maxVolunteers\n    OPEN --> CANCELLED : Admin cancels\n    FULL --> OPEN : Volunteer cancels (currentVolunteers < maxVolunteers)\n    FULL --> CANCELLED : Admin cancels\n    CANCELLED --> [*]
"},{"location":"v2/database/models/map/#common-queries","title":"Common Queries","text":""},{"location":"v2/database/models/map/#create-location-with-geocoding","title":"Create Location with Geocoding","text":"
const location = await prisma.location.create({\n  data: {\n    address: '123 Main St, Edmonton, AB',\n    latitude: 53.5461,\n    longitude: -113.4938,\n    geocodeProvider: GeocodeProvider.GOOGLE,\n    geocodeConfidence: 95,\n    buildingType: BuildingType.SINGLE_FAMILY,\n    totalUnits: 1,\n    createdByUserId: user.id,\n    history: {\n      create: {\n        userId: user.id,\n        action: LocationHistoryAction.GEOCODED,\n        metadata: { provider: 'google', confidence: 95 },\n      },\n    },\n  },\n});\n
"},{"location":"v2/database/models/map/#find-locations-in-bounding-box","title":"Find Locations in Bounding Box","text":"
const locations = await prisma.location.findMany({\n  where: {\n    latitude: { gte: 53.5, lte: 53.6 },\n    longitude: { gte: -113.5, lte: -113.4 },\n  },\n  include: { addresses: true },\n});\n
"},{"location":"v2/database/models/map/#create-shift-with-cut","title":"Create Shift with Cut","text":"
const shift = await prisma.shift.create({\n  data: {\n    title: 'Weekend Canvassing - Downtown',\n    date: new Date('2025-02-15'),\n    startTime: '10:00',\n    endTime: '14:00',\n    maxVolunteers: 10,\n    isPublic: true,\n    cutId: cut.id,  // Assign to cut\n  },\n});\n
"},{"location":"v2/database/models/map/#public-shift-signup-creates-temp-user","title":"Public Shift Signup (Creates TEMP User)","text":"
// 1. Create TEMP user with random password\nconst tempPassword = generatePassword(); // \"SwiftEagle42\"\nconst tempUser = await prisma.user.create({\n  data: {\n    email: 'volunteer@example.com',\n    password: await bcrypt.hash(tempPassword, 10),\n    name: 'Jane Volunteer',\n    role: UserRole.TEMP,\n    createdVia: UserCreatedVia.PUBLIC_SHIFT_SIGNUP,\n    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days\n    expireDays: 30,\n  },\n});\n\n// 2. Create shift signup\nawait prisma.shiftSignup.create({\n  data: {\n    shiftId: shift.id,\n    userId: tempUser.id,\n    userEmail: 'volunteer@example.com',\n    userName: 'Jane Volunteer',\n    signupSource: SignupSource.PUBLIC,\n  },\n});\n\n// 3. Send confirmation email with temp password\nawait emailService.send({\n  template: 'shift-signup-confirmation',\n  variables: {\n    USER_NAME: 'Jane Volunteer',\n    SHIFT_TITLE: shift.title,\n    IS_NEW_USER: 'true',\n    TEMP_PASSWORD: tempPassword,\n    // ...\n  },\n});\n
"},{"location":"v2/database/models/map/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/database/models/media/","title":"Media Models (Drizzle ORM)","text":""},{"location":"v2/database/models/media/#overview","title":"Overview","text":"

The Media module uses Drizzle ORM (separate from Prisma) to manage video library, compilations, and job queue.

Models (3): - videos \u2014 Video library with metadata - compilations \u2014 Video compilation tracking - jobs \u2014 Job queue with resource management

ORM: Drizzle (not Prisma) API: Fastify (port 4100, separate from Express main API) Migration: npx drizzle-kit push (no migration files)

Key Features: - FFprobe metadata extraction (duration, dimensions, orientation, quality, audio) - Directory type enum (9 types: studios, gifs, private, inbox, curated, playback, compilations, videos, highlights) - Public media engagement stats (historical) - Job queue with GPU/CPU resource categories - Video upload with automatic metadata extraction

See Schema Reference for complete field listings.

"},{"location":"v2/database/models/media/#directory-types","title":"Directory Types","text":"
export const DIRECTORY_TYPES = [\n  'studios',\n  'gifs',\n  'private',\n  'inbox',\n  'curated',\n  'playback',\n  'compilations',\n  'videos',\n  'highlights'\n] as const;\n

Usage: - Efficient filtering (indexed) - Replaces LIKE patterns (e.g., path LIKE '%/studios/%')

"},{"location":"v2/database/models/media/#video-metadata-ffprobe","title":"Video Metadata (FFprobe)","text":"

Extracted Fields: - durationSeconds \u2014 Video duration in seconds - width / height \u2014 Video dimensions (pixels) - orientation \u2014 portrait, landscape, square - quality \u2014 1080p, 720p, 480p, etc. - hasAudio \u2014 Audio track present flag

Extraction Service: api/src/modules/media/services/ffprobe.service.ts Timeout: 30 seconds for metadata extraction Validation: Decodes 5 frames with 60s timeout

"},{"location":"v2/database/models/media/#job-queue","title":"Job Queue","text":"

Resource Categories:

export type ResourceCategory = 'gpu_ai' | 'gpu_encode' | 'cpu';\n

Job Status:

export type JobStatus = 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';\n

Job Types (21 total): - compilation, scan, public_scan, organize, organize_studio - reencode_streaming, compile_random, compile_quad, compile_quad_horizontal - compile_triple_vertical, compile_mega, compile_gif, generate_gif - fetch, digest, digest_generate, clip_generate, highlight_generate - tag_generation, scene_extract, clip_extract_only, auto_organize_publish

Queue Processing: - Ordered by: status (pending first), priority (lower = higher), createdAt (FIFO) - Uses composite index: [status, priority, createdAt]

"},{"location":"v2/database/models/media/#video-upload-flow","title":"Video Upload Flow","text":"
sequenceDiagram\n    participant Client\n    participant API\n    participant FFprobe\n    participant DB\n\n    Client->>API: POST /api/media/upload (multipart/form-data)\n    API->>API: Stream file to /media/local/inbox\n    API->>FFprobe: Extract metadata (duration, width, height, etc.)\n    FFprobe-->>API: metadata\n    API->>DB: INSERT INTO videos (path, filename, durationSeconds, ...)\n    DB-->>API: video record\n    API-->>Client: { id, path, metadata }

Volume Mount: /media/local/inbox:rw (read-write), library remains :ro

"},{"location":"v2/database/models/media/#drizzle-schema-example","title":"Drizzle Schema Example","text":"
export const videos = pgTable('videos', {\n  id: serial('id').primaryKey(),\n  path: text('path').notNull().unique(),\n  filename: text('filename').notNull(),\n  durationSeconds: integer('duration_seconds'),\n  quality: text('quality'),\n  orientation: text('orientation'),\n  hasAudio: boolean('has_audio').default(true),\n  width: integer('width'),\n  height: integer('height'),\n  directoryType: text('directory_type').$type<DirectoryType>(),\n  tags: jsonb('tags').$type<string[]>(),\n  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),\n}, (table) => ({\n  directoryTypeIdx: index('idx_directory_type').on(table.directoryType),\n  fingerprintIdx: index('idx_videos_fingerprint').on(\n    table.durationSeconds,\n    table.fileSize,\n    table.width,\n    table.height\n  ),\n}));\n
"},{"location":"v2/database/models/media/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/database/models/pages/","title":"Landing Page Models","text":""},{"location":"v2/database/models/pages/#overview","title":"Overview","text":"

The Landing Page module provides a WYSIWYG page builder with GrapesJS editor integration, reusable block library, and MkDocs export functionality.

Models (2): - LandingPage \u2014 GrapesJS editor output with MkDocs export - PageBlock \u2014 Reusable block library

Key Features: - GrapesJS WYSIWYG editor (desktop only) - Visual + code editor modes - MkDocs export (THEMED vs STANDALONE modes) - SEO metadata (title, description, image) - Reusable block library (6 default blocks) - Jinja2 Material theme integration

See Schema Reference for complete field listings.

"},{"location":"v2/database/models/pages/#editor-modes","title":"Editor Modes","text":"
enum EditorMode {\n  VISUAL  // GrapesJS visual editor (default)\n  CODE    // Raw HTML/CSS code editor\n}\n
"},{"location":"v2/database/models/pages/#mkdocs-export-modes","title":"MkDocs Export Modes","text":"
enum MkdocsExportMode {\n  THEMED      // Extends main.html, content block only (default)\n  STANDALONE  // Full HTML document, no Jinja2 inheritance\n}\n

THEMED Mode:

{% extends \"main.html\" %}\n{% block content %}\n  <div class=\"landing-page\">\n    <!-- Page HTML here -->\n  </div>\n{% endblock %}\n

STANDALONE Mode:

<!DOCTYPE html>\n<html>\n<head>\n  <title>Page Title</title>\n  <!-- Full HTML document -->\n</head>\n<body>\n  <!-- Page HTML here -->\n</body>\n</html>\n

"},{"location":"v2/database/models/pages/#page-blocks-6-default","title":"Page Blocks (6 default)","text":"

1. Hero Section (Headers) - Schema: title, subtitle, backgroundImage, ctaText, ctaUrl - Defaults: \"Welcome to Our Campaign\", \"Get Involved\"

2. Text Block (Content) - Schema: heading, body - Defaults: \"About Us\", \"Tell your story here...\"

3. Features Grid (Content) - Schema: features[] (title, description, icon) - Defaults: 3 features (Community Action, Advocacy, Volunteer)

4. Call to Action (Actions) - Schema: heading, description, buttonText, buttonUrl - Defaults: \"Ready to Take Action?\", \"Join Now\"

5. Testimonials (Content) - Schema: quotes[] (text, author, role) - Defaults: 2 quotes

6. Contact Form (Actions) - Schema: heading, fields[] (name, type, required) - Defaults: Name, Email, Message fields

"},{"location":"v2/database/models/pages/#grapesjs-json-format","title":"GrapesJS JSON Format","text":"

blocks Field:

{\n  \"pages\": [\n    {\n      \"id\": \"page-main\",\n      \"component\": {\n        \"type\": \"wrapper\",\n        \"components\": [\n          {\n            \"tagName\": \"section\",\n            \"classes\": [\"hero\"],\n            \"components\": [\n              {\n                \"tagName\": \"h1\",\n                \"content\": \"Welcome to Our Campaign\"\n              }\n            ]\n          }\n        ]\n      }\n    }\n  ]\n}\n

"},{"location":"v2/database/models/pages/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/database/models/settings/","title":"Settings Models","text":""},{"location":"v2/database/models/settings/#overview","title":"Overview","text":"

The Settings module provides two singleton configuration models for global site settings and map-specific settings.

Models (2): - SiteSettings \u2014 Org branding + theme + SMTP + feature toggles - MapSettings \u2014 Map center/zoom + walk sheet config

Key Features: - Singleton pattern (always ID \"default\") - SMTP override hierarchy (SiteSettings \u2192 env vars) - Feature flags (enableInfluence, enableMap, enableNewsletter, enableLandingPages) - Theme color customization (admin + public) - Walk sheet customization (title, subtitle, footer, QR codes)

See Schema Reference for complete field listings.

"},{"location":"v2/database/models/settings/#sitesettings-singleton","title":"SiteSettings (Singleton)","text":"

ID: Always \"default\" (enforced by seed + UI)

Sections: 1. Organization \u2014 Name, logo, favicon 2. Admin Theme \u2014 Primary color, background color 3. Public Theme \u2014 Primary color, background color, container color, header gradient 4. Email Branding \u2014 From name, footer text, login subtitle 5. SMTP Configuration \u2014 Host, port, user, pass, from address, active provider, test mode 6. Feature Toggles \u2014 Enable/disable modules

SMTP Hierarchy: - If SiteSettings.smtpHost is set \u2192 use SiteSettings SMTP - Else \u2192 fallback to env vars (SMTP_HOST, SMTP_PORT, etc.)

"},{"location":"v2/database/models/settings/#mapsettings-singleton","title":"MapSettings (Singleton)","text":"

ID: Always \"default\" (enforced by seed + UI)

Sections: 1. Map Center \u2014 Latitude, longitude, zoom (default: Edmonton, AB) 2. Walk Sheet \u2014 Title, subtitle, footer text 3. QR Codes \u2014 3 QR code slots (URL + label each)

QR Code Usage: - Rendered on printable walk sheets - Typically links to volunteer portal, shift signup, campaign page - Generated via Mini QR service (GET /api/qr?url=...)

"},{"location":"v2/database/models/settings/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/deployment/","title":"Deployment Overview","text":"

This section covers deploying Changemaker Lite V2 to production, including Docker orchestration, environment configuration, SSL/TLS setup, monitoring, backups, and scaling strategies.

"},{"location":"v2/deployment/#deployment-guide","title":"Deployment Guide","text":""},{"location":"v2/deployment/#docker-compose","title":"Docker Compose","text":"

Complete Docker orchestration for all services:

"},{"location":"v2/deployment/#environment-variables","title":"Environment Variables","text":"

Comprehensive environment configuration:

"},{"location":"v2/deployment/#nginx-configuration","title":"Nginx Configuration","text":"

Reverse proxy and routing:

"},{"location":"v2/deployment/#ssltls-setup","title":"SSL/TLS Setup","text":"

HTTPS configuration:

"},{"location":"v2/deployment/#tunneling","title":"Tunneling","text":"

Public access via tunneling:

"},{"location":"v2/deployment/#backup-restore","title":"Backup & Restore","text":"

Data protection:

"},{"location":"v2/deployment/#monitoring-stack","title":"Monitoring Stack","text":"

Observability and alerting:

"},{"location":"v2/deployment/#healthchecks","title":"Healthchecks","text":"

Container health monitoring:

"},{"location":"v2/deployment/#scaling","title":"Scaling","text":"

Horizontal and vertical scaling:

"},{"location":"v2/deployment/#quick-start","title":"Quick Start","text":""},{"location":"v2/deployment/#initial-deployment","title":"Initial Deployment","text":"
  1. Prepare Server

    # Ubuntu/Debian server with Docker installed\napt update && apt install docker.io docker-compose git\n

  2. Clone Repository

    git clone <repo-url> changemaker.lite\ncd changemaker.lite\ngit checkout v2\n

  3. Configure Environment

    cp .env.example .env\n# Edit .env with your settings\n

  4. Start Services

    docker compose up -d v2-postgres redis\ndocker compose up -d api admin\ndocker compose exec api npx prisma migrate deploy\ndocker compose exec api npx prisma db seed\n

  5. Access Application

    http://server-ip:3000\nLogin: admin@example.com / Admin123!\n

"},{"location":"v2/deployment/#production-deployment","title":"Production Deployment","text":"
  1. Configure Tunneling (for public access)
  2. Set up Pangolin account
  3. Configure tunnel in admin UI
  4. Deploy Newt container

  5. Enable Monitoring

    docker compose --profile monitoring up -d\n

  6. Set Up Backups

    # Configure backup.sh\n./scripts/backup.sh\n\n# Add to crontab\n0 2 * * * /path/to/backup.sh\n

  7. Secure Installation

  8. Change default passwords
  9. Enable Redis auth
  10. Configure firewall
  11. Review security audit
"},{"location":"v2/deployment/#architecture-overview","title":"Architecture Overview","text":""},{"location":"v2/deployment/#service-topology","title":"Service Topology","text":"
Internet\n  \u2193\nPangolin Tunnel / Cloudflare\n  \u2193\nNewt Container / Tunnel Daemon\n  \u2193\nNginx (Reverse Proxy)\n  \u2193\n  \u251c\u2192 Admin GUI (React, port 3000)\n  \u251c\u2192 Express API (TypeScript, port 4000)\n  \u251c\u2192 Media API (Fastify, port 4100)\n  \u251c\u2192 MkDocs (Documentation, port 4003)\n  \u251c\u2192 Grafana (Monitoring, port 3001)\n  \u2514\u2192 Other Services...\n  \u2193\n  \u251c\u2192 PostgreSQL 16 (Database, port 5433)\n  \u251c\u2192 Redis 7 (Cache/Queue, port 6379)\n  \u2514\u2192 Supporting Services...\n
"},{"location":"v2/deployment/#port-reference","title":"Port Reference","text":"Port Service Access 3000 Admin GUI Public 4000 Express API Public 4100 Media API Public 5433 PostgreSQL Internal 6379 Redis Internal 3001 Grafana Public 9090 Prometheus Internal 8091 NocoDB Public 9001 Listmonk Public"},{"location":"v2/deployment/#subdomain-routing","title":"Subdomain Routing","text":"Subdomain Target Purpose app.cmlite.org Admin:3000 Admin interface api.cmlite.org API:4000 Express API media.cmlite.org Media:4100 Media API docs.cmlite.org MkDocs:4003 Documentation grafana.cmlite.org Grafana:3001 Monitoring db.cmlite.org NocoDB:8091 Data browser listmonk.cmlite.org Listmonk:9001 Newsletters"},{"location":"v2/deployment/#production-checklist","title":"Production Checklist","text":""},{"location":"v2/deployment/#security","title":"Security","text":""},{"location":"v2/deployment/#environment","title":"Environment","text":""},{"location":"v2/deployment/#services","title":"Services","text":""},{"location":"v2/deployment/#monitoring","title":"Monitoring","text":""},{"location":"v2/deployment/#backups","title":"Backups","text":""},{"location":"v2/deployment/#public-access","title":"Public Access","text":""},{"location":"v2/deployment/#maintenance","title":"Maintenance","text":""},{"location":"v2/deployment/#regular-tasks","title":"Regular Tasks","text":"

Daily: - Monitor service health - Review error logs - Check disk space

Weekly: - Review backup success - Check queue depths - Update dependencies (if needed)

Monthly: - Security updates - Database optimization - Log rotation - Certificate renewal check

"},{"location":"v2/deployment/#updates","title":"Updates","text":"
  1. Pull Latest Code

    git pull origin v2\n

  2. Rebuild Containers

    docker compose build\ndocker compose up -d\n

  3. Run Migrations

    docker compose exec api npx prisma migrate deploy\n

  4. Verify Services

    docker compose ps\ncurl http://localhost:4000/health\n

"},{"location":"v2/deployment/#troubleshooting","title":"Troubleshooting","text":"

Common deployment issues:

See Troubleshooting Guide for detailed solutions.

"},{"location":"v2/deployment/#resource-requirements","title":"Resource Requirements","text":""},{"location":"v2/deployment/#minimum","title":"Minimum","text":""},{"location":"v2/deployment/#recommended","title":"Recommended","text":""},{"location":"v2/deployment/#high-load","title":"High Load","text":""},{"location":"v2/deployment/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/deployment/backup-restore/","title":"Backup & Restore Procedures","text":""},{"location":"v2/deployment/backup-restore/#overview","title":"Overview","text":"

The scripts/backup.sh script provides automated backups of: - V2 PostgreSQL database (pg_dump) - Listmonk PostgreSQL database (pg_dump) - Uploads directory (tar.gz) - Backup manifest (SHA256 checksums)

Optional S3 upload for offsite storage.

"},{"location":"v2/deployment/backup-restore/#quick-start","title":"Quick Start","text":""},{"location":"v2/deployment/backup-restore/#manual-backup","title":"Manual Backup","text":"
# Basic backup (local only)\n./scripts/backup.sh\n\n# With S3 upload\n./scripts/backup.sh --s3\n\n# Custom retention (60 days)\n./scripts/backup.sh --retention 60\n

Output: backups/changemaker-v2-backup-YYYYMMDD_HHMMSS.tar.gz

"},{"location":"v2/deployment/backup-restore/#automated-backups-cron","title":"Automated Backups (Cron)","text":"
# Edit crontab\ncrontab -e\n\n# Daily backup at 2 AM + S3 upload\n0 2 * * * /home/user/changemaker.lite/scripts/backup.sh --s3 >> /var/log/changemaker-backup.log 2>&1\n\n# Weekly backup on Sundays at 3 AM\n0 3 * * 0 /home/user/changemaker.lite/scripts/backup.sh --s3 --retention 90\n
"},{"location":"v2/deployment/backup-restore/#backup-script-walkthrough","title":"Backup Script Walkthrough","text":""},{"location":"v2/deployment/backup-restore/#configuration","title":"Configuration","text":"

Location: scripts/backup.sh

Variables:

BACKUP_DIR=\"${BACKUP_DIR:-./backups}\"     # Backup output directory\nRETENTION_DAYS=\"${RETENTION_DAYS:-30}\"    # Delete backups older than N days\nTIMESTAMP=\"$(date +%Y%m%d_%H%M%S)\"        # Backup timestamp\n

Environment: Loads .env automatically (safe parsing handles quotes/special chars).

"},{"location":"v2/deployment/backup-restore/#backup-steps","title":"Backup Steps","text":""},{"location":"v2/deployment/backup-restore/#1-v2-postgresql-dump","title":"1. V2 PostgreSQL Dump","text":"
docker exec changemaker-v2-postgres \\\n  pg_dump -U changemaker -d changemaker_v2 --no-owner --no-acl \\\n  | gzip > v2-postgres.sql.gz\n

Options: - --no-owner: Skip ownership commands (easier restore) - --no-acl: Skip permissions (easier restore) - gzip: Compress (70-80% reduction)

Size estimate: 100MB-2GB (depends on data volume).

"},{"location":"v2/deployment/backup-restore/#2-listmonk-postgresql-dump","title":"2. Listmonk PostgreSQL Dump","text":"
docker exec listmonk-db \\\n  pg_dump -U listmonk -d listmonk --no-owner --no-acl \\\n  | gzip > listmonk-postgres.sql.gz\n

Optional: Skipped if Listmonk container not running.

Size estimate: 10MB-500MB (depends on subscriber count + campaigns).

"},{"location":"v2/deployment/backup-restore/#3-uploads-archive","title":"3. Uploads Archive","text":"
tar -czf uploads.tar.gz -C assets/ uploads/\n

Includes: - Campaign email attachments - Response wall images - Listmonk campaign uploads

Size estimate: 100MB-10GB (depends on file uploads).

"},{"location":"v2/deployment/backup-restore/#4-backup-manifest","title":"4. Backup Manifest","text":"

Format: JSON with file list + SHA256 checksums.

{\n  \"timestamp\": \"20260213_140530\",\n  \"backup_name\": \"changemaker-v2-backup-20260213_140530\",\n  \"files\": [\n    {\n      \"file\": \"v2-postgres.sql.gz\",\n      \"size_bytes\": 123456789,\n      \"sha256\": \"abc123...\"\n    },\n    {\n      \"file\": \"listmonk-postgres.sql.gz\",\n      \"size_bytes\": 987654,\n      \"sha256\": \"def456...\"\n    },\n    {\n      \"file\": \"uploads.tar.gz\",\n      \"size_bytes\": 555666777,\n      \"sha256\": \"ghi789...\"\n    }\n  ],\n  \"v2_database\": \"changemaker_v2\",\n  \"listmonk_database\": \"listmonk\",\n  \"retention_days\": 30\n}\n

Purpose: Verify backup integrity + metadata.

"},{"location":"v2/deployment/backup-restore/#final-archive","title":"Final Archive","text":"

Creates single tar.gz:

tar -czf changemaker-v2-backup-20260213_140530.tar.gz \\\n  changemaker-v2-backup-20260213_140530/\n

Removes temp directory after archiving.

"},{"location":"v2/deployment/backup-restore/#optional-s3-upload","title":"Optional S3 Upload","text":"

Requires: - AWS CLI installed (apt install awscli) - Credentials configured (aws configure) - S3_BUCKET env var set

Command:

aws s3 cp changemaker-v2-backup-20260213_140530.tar.gz \\\n  s3://${S3_BUCKET}/${S3_PREFIX}/\n

S3 prefix: ${S3_PREFIX:-changemaker-backups} (customizable).

"},{"location":"v2/deployment/backup-restore/#retention-cleanup","title":"Retention Cleanup","text":"

Deletes backups older than RETENTION_DAYS:

find backups/ -name \"changemaker-v2-backup-*.tar.gz\" -mtime +30 -delete\n

Local only (S3 has its own lifecycle policies).

"},{"location":"v2/deployment/backup-restore/#restore-procedures","title":"Restore Procedures","text":""},{"location":"v2/deployment/backup-restore/#full-restore-new-server","title":"Full Restore (New Server)","text":""},{"location":"v2/deployment/backup-restore/#1-extract-backup","title":"1. Extract Backup","text":"
# Download from S3 (if needed)\naws s3 cp s3://my-bucket/changemaker-backups/changemaker-v2-backup-20260213_140530.tar.gz ./\n\n# Extract archive\ntar -xzf changemaker-v2-backup-20260213_140530.tar.gz\ncd changemaker-v2-backup-20260213_140530/\n
"},{"location":"v2/deployment/backup-restore/#2-restore-v2-database","title":"2. Restore V2 Database","text":"
# Start PostgreSQL container\ndocker compose up -d v2-postgres\n\n# Wait for healthy\ndocker compose ps v2-postgres\n\n# Restore dump\ngunzip -c v2-postgres.sql.gz | \\\n  docker exec -i changemaker-v2-postgres \\\n  psql -U changemaker -d changemaker_v2\n\n# Verify\ndocker compose exec v2-postgres \\\n  psql -U changemaker -d changemaker_v2 -c \"\\dt\"\n
"},{"location":"v2/deployment/backup-restore/#3-restore-listmonk-database","title":"3. Restore Listmonk Database","text":"
# Start Listmonk DB\ndocker compose up -d listmonk-db\n\n# Restore dump\ngunzip -c listmonk-postgres.sql.gz | \\\n  docker exec -i listmonk-db \\\n  psql -U listmonk -d listmonk\n\n# Verify\ndocker compose exec listmonk-db \\\n  psql -U listmonk -d listmonk -c \"SELECT COUNT(*) FROM subscribers\"\n
"},{"location":"v2/deployment/backup-restore/#4-restore-uploads","title":"4. Restore Uploads","text":"
# Extract uploads\ntar -xzf uploads.tar.gz -C ./assets/\n\n# Verify\nls -lh assets/uploads/\n
"},{"location":"v2/deployment/backup-restore/#5-start-services","title":"5. Start Services","text":"
# Start all services\ndocker compose up -d\n\n# Run migrations (if needed)\ndocker compose exec api npx prisma migrate deploy\n\n# Check health\ndocker compose ps\ncurl http://localhost:4000/api/health\n
"},{"location":"v2/deployment/backup-restore/#partial-restore-specific-data","title":"Partial Restore (Specific Data)","text":""},{"location":"v2/deployment/backup-restore/#restore-single-table","title":"Restore Single Table","text":"
# Extract table from dump\npg_restore -U changemaker -d changemaker_v2 \\\n  --table=campaigns \\\n  v2-postgres.sql.gz\n\n# Or: restore from SQL dump\ngunzip -c v2-postgres.sql.gz | \\\n  grep -A9999 \"CREATE TABLE campaigns\" | \\\n  grep -B9999 \"CREATE TABLE \" | \\\n  docker exec -i changemaker-v2-postgres \\\n  psql -U changemaker -d changemaker_v2\n
"},{"location":"v2/deployment/backup-restore/#restore-specific-files","title":"Restore Specific Files","text":"
# List files in upload archive\ntar -tzf uploads.tar.gz\n\n# Extract specific file\ntar -xzf uploads.tar.gz uploads/campaigns/logo.png\n\n# Copy to container\ndocker cp uploads/campaigns/logo.png \\\n  changemaker-v2-api:/app/uploads/campaigns/\n
"},{"location":"v2/deployment/backup-restore/#backup-verification","title":"Backup Verification","text":""},{"location":"v2/deployment/backup-restore/#integrity-check","title":"Integrity Check","text":"
# Verify checksums from manifest\ncd changemaker-v2-backup-20260213_140530/\n\n# Check v2-postgres.sql.gz\necho \"abc123...  v2-postgres.sql.gz\" | sha256sum -c\n\n# Check all files\njq -r '.files[] | \"\\(.sha256)  \\(.file)\"' manifest.json | sha256sum -c\n

Expected output: OK for each file.

"},{"location":"v2/deployment/backup-restore/#test-restore-dry-run","title":"Test Restore (Dry Run)","text":"

Best practice: Periodically test restores.

# Restore to test database\ndocker compose up -d v2-postgres\n\n# Create test DB\ndocker compose exec v2-postgres \\\n  psql -U changemaker -c \"CREATE DATABASE changemaker_v2_test\"\n\n# Restore to test DB\ngunzip -c v2-postgres.sql.gz | \\\n  docker exec -i changemaker-v2-postgres \\\n  psql -U changemaker -d changemaker_v2_test\n\n# Verify data\ndocker compose exec v2-postgres \\\n  psql -U changemaker -d changemaker_v2_test -c \"SELECT COUNT(*) FROM users\"\n\n# Drop test DB\ndocker compose exec v2-postgres \\\n  psql -U changemaker -c \"DROP DATABASE changemaker_v2_test\"\n
"},{"location":"v2/deployment/backup-restore/#s3-configuration","title":"S3 Configuration","text":""},{"location":"v2/deployment/backup-restore/#setup-aws-cli","title":"Setup AWS CLI","text":"
# Install\nsudo apt install awscli\n\n# Configure credentials\naws configure\n# AWS Access Key ID: <your-key>\n# AWS Secret Access Key: <your-secret>\n# Default region: us-east-1\n# Default output format: json\n
"},{"location":"v2/deployment/backup-restore/#create-s3-bucket","title":"Create S3 Bucket","text":"
# Create bucket\naws s3 mb s3://changemaker-backups\n\n# Set lifecycle policy (auto-delete old backups)\ncat > lifecycle.json <<EOF\n{\n  \"Rules\": [\n    {\n      \"Id\": \"DeleteOldBackups\",\n      \"Status\": \"Enabled\",\n      \"Prefix\": \"changemaker-backups/\",\n      \"Expiration\": {\n        \"Days\": 90\n      }\n    }\n  ]\n}\nEOF\n\naws s3api put-bucket-lifecycle-configuration \\\n  --bucket changemaker-backups \\\n  --lifecycle-configuration file://lifecycle.json\n
"},{"location":"v2/deployment/backup-restore/#environment-variables","title":"Environment Variables","text":"
# Add to .env\nS3_BUCKET=changemaker-backups\nS3_PREFIX=changemaker-backups\nAWS_ACCESS_KEY_ID=<your-key>\nAWS_SECRET_ACCESS_KEY=<your-secret>\nAWS_DEFAULT_REGION=us-east-1\n
"},{"location":"v2/deployment/backup-restore/#retention-policies","title":"Retention Policies","text":""},{"location":"v2/deployment/backup-restore/#recommended-strategy","title":"Recommended Strategy","text":"

Daily backups: Keep 7 days Weekly backups: Keep 4 weeks Monthly backups: Keep 12 months

Implementation (via cron):

# Daily (keep 7 days)\n0 2 * * * /path/to/backup.sh --retention 7\n\n# Weekly (Sundays, keep 28 days)\n0 3 * * 0 /path/to/backup.sh --retention 28 --s3\n\n# Monthly (1st of month, keep 365 days)\n0 4 1 * * /path/to/backup.sh --retention 365 --s3\n

"},{"location":"v2/deployment/backup-restore/#s3-lifecycle","title":"S3 Lifecycle","text":"

Glacier transition (archive old backups):

{\n  \"Rules\": [\n    {\n      \"Id\": \"ArchiveOldBackups\",\n      \"Status\": \"Enabled\",\n      \"Transitions\": [\n        {\n          \"Days\": 30,\n          \"StorageClass\": \"GLACIER\"\n        }\n      ],\n      \"Expiration\": {\n        \"Days\": 365\n      }\n    }\n  ]\n}\n

Apply:

aws s3api put-bucket-lifecycle-configuration \\\n  --bucket changemaker-backups \\\n  --lifecycle-configuration file://lifecycle.json\n

"},{"location":"v2/deployment/backup-restore/#disaster-recovery","title":"Disaster Recovery","text":""},{"location":"v2/deployment/backup-restore/#complete-server-loss","title":"Complete Server Loss","text":"

Scenario: Server crashes, all data lost.

Recovery Steps:

  1. Provision new server (same OS, Docker installed)
  2. Clone repository:
    git clone <repo> changemaker.lite\ncd changemaker.lite\ngit checkout v2\n
  3. Restore .env file (from secure backup location)
  4. Download latest backup from S3:
    aws s3 cp s3://changemaker-backups/changemaker-backups/latest.tar.gz ./\n
  5. Extract + restore (see Full Restore above)
  6. Start services:
    docker compose up -d\n
  7. Verify:
    docker compose ps\ncurl http://localhost:4000/api/health\n

RTO (Recovery Time Objective): 30-60 minutes RPO (Recovery Point Objective): Last backup (e.g., 24h for daily backups)

"},{"location":"v2/deployment/backup-restore/#database-corruption","title":"Database Corruption","text":"

Scenario: PostgreSQL data corruption detected.

Recovery:

# Stop services\ndocker compose stop api admin\n\n# Drop corrupted database\ndocker compose exec v2-postgres \\\n  psql -U changemaker -c \"DROP DATABASE changemaker_v2\"\n\n# Recreate database\ndocker compose exec v2-postgres \\\n  psql -U changemaker -c \"CREATE DATABASE changemaker_v2\"\n\n# Restore from backup\ngunzip -c backups/latest/v2-postgres.sql.gz | \\\n  docker exec -i changemaker-v2-postgres \\\n  psql -U changemaker -d changemaker_v2\n\n# Restart services\ndocker compose up -d api admin\n

"},{"location":"v2/deployment/backup-restore/#monitoring-backup-success","title":"Monitoring Backup Success","text":""},{"location":"v2/deployment/backup-restore/#log-files","title":"Log Files","text":"

Cron output:

# View last backup log\ntail -f /var/log/changemaker-backup.log\n\n# Check for errors\ngrep -i error /var/log/changemaker-backup.log\n

"},{"location":"v2/deployment/backup-restore/#prometheus-metrics-custom","title":"Prometheus Metrics (Custom)","text":"

Add to api/src/utils/metrics.ts:

export const lastBackupTimestamp = new client.Gauge({\n  name: 'cm_last_backup_timestamp',\n  help: 'Unix timestamp of last successful backup',\n});\n\nexport const backupSizeBytes = new client.Gauge({\n  name: 'cm_backup_size_bytes',\n  help: 'Size of last backup in bytes',\n});\n

Alert rule:

- alert: BackupTooOld\n  expr: time() - cm_last_backup_timestamp > 86400 * 2  # 2 days\n  for: 1h\n  labels:\n    severity: warning\n  annotations:\n    summary: \"Backup older than 2 days\"\n

"},{"location":"v2/deployment/backup-restore/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/backup-restore/#pg_dump-permission-denied","title":"pg_dump: permission denied","text":"

Symptoms: Backup fails with \"permission denied for database\"

Cause: PostgreSQL user lacks dump privileges.

Solution:

# Grant privileges\ndocker compose exec v2-postgres \\\n  psql -U changemaker -c \"GRANT ALL ON DATABASE changemaker_v2 TO changemaker\"\n\n# Retry backup\n./scripts/backup.sh\n

"},{"location":"v2/deployment/backup-restore/#s3-upload-fails-invalidaccesskeyid","title":"S3 upload fails: InvalidAccessKeyId","text":"

Symptoms: AWS CLI authentication error

Solution:

# Verify credentials\naws sts get-caller-identity\n\n# Reconfigure\naws configure\n\n# Test S3 access\naws s3 ls s3://changemaker-backups/\n

"},{"location":"v2/deployment/backup-restore/#restore-fails-relation-already-exists","title":"Restore fails: relation already exists","text":"

Symptoms: psql: ERROR: relation \"users\" already exists

Cause: Restoring to non-empty database.

Solution:

# Drop and recreate database\ndocker compose exec v2-postgres \\\n  psql -U changemaker <<SQL\nDROP DATABASE changemaker_v2;\nCREATE DATABASE changemaker_v2;\nSQL\n\n# Retry restore\ngunzip -c v2-postgres.sql.gz | \\\n  docker exec -i changemaker-v2-postgres \\\n  psql -U changemaker -d changemaker_v2\n

"},{"location":"v2/deployment/backup-restore/#best-practices","title":"Best Practices","text":""},{"location":"v2/deployment/backup-restore/#security","title":"Security","text":""},{"location":"v2/deployment/backup-restore/#automation","title":"Automation","text":""},{"location":"v2/deployment/backup-restore/#documentation","title":"Documentation","text":""},{"location":"v2/deployment/backup-restore/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/deployment/docker-compose/","title":"Docker Compose Orchestration","text":""},{"location":"v2/deployment/docker-compose/#overview","title":"Overview","text":"

Changemaker Lite V2 uses Docker Compose to orchestrate 20+ microservices in a single unified stack. This approach simplifies deployment, provides service isolation, and ensures consistent environments across development and production.

Key Benefits:

Architecture:

The V2 stack consolidates all services into a single Docker Compose file, replacing the fragmented V1 approach. Services are organized into logical groups: Core (API, database, admin), Supporting (NocoDB, Listmonk, Gitea), Media (media-api, public-media), and Monitoring (Prometheus, Grafana, exporters).

"},{"location":"v2/deployment/docker-compose/#service-architecture","title":"Service Architecture","text":"
graph TB\n    subgraph \"Core Services\"\n        NGINX[nginx<br/>:80, :443]\n        API[api<br/>Express :4000]\n        MEDIA[media-api<br/>Fastify :4100]\n        ADMIN[admin<br/>Vite :3000]\n        PG[v2-postgres<br/>PostgreSQL 16]\n        REDIS[redis<br/>:6379]\n    end\n\n    subgraph \"Supporting Services\"\n        NOCODB[nocodb-v2<br/>:8091]\n        LISTMONK[listmonk-app<br/>:9000]\n        LISTMONK_DB[listmonk-db<br/>PostgreSQL 17]\n        MAILHOG[mailhog<br/>:8025]\n        GITEA[gitea-app<br/>:3000]\n        GITEA_DB[gitea-db<br/>MySQL 8]\n        N8N[n8n<br/>:5678]\n        MKDOCS[mkdocs<br/>:8000]\n        CODE[code-server<br/>:8080]\n        HOMEPAGE[homepage<br/>:3000]\n        MINIQR[mini-qr<br/>:8080]\n    end\n\n    subgraph \"Media Services\"\n        PUBLIC_MEDIA[public-media<br/>:80]\n    end\n\n    subgraph \"Tunnel Services\"\n        NEWT[newt<br/>Pangolin connector]\n    end\n\n    subgraph \"Monitoring Services (profile: monitoring)\"\n        PROMETHEUS[prometheus<br/>:9090]\n        GRAFANA[grafana<br/>:3000]\n        CADVISOR[cadvisor<br/>:8080]\n        NODE_EXPORTER[node-exporter<br/>:9100]\n        REDIS_EXPORTER[redis-exporter<br/>:9121]\n        ALERTMANAGER[alertmanager<br/>:9093]\n        GOTIFY[gotify<br/>:80]\n    end\n\n    NGINX --> API\n    NGINX --> MEDIA\n    NGINX --> ADMIN\n    NGINX --> NOCODB\n    NGINX --> LISTMONK\n    NGINX --> GITEA\n    NGINX --> N8N\n    NGINX --> MKDOCS\n    NGINX --> CODE\n    NGINX --> HOMEPAGE\n    NGINX --> MINIQR\n    NGINX --> MAILHOG\n    NGINX --> PUBLIC_MEDIA\n\n    API --> PG\n    API --> REDIS\n    MEDIA --> PG\n    ADMIN --> API\n    ADMIN --> MEDIA\n    NOCODB --> PG\n    LISTMONK --> LISTMONK_DB\n    GITEA --> GITEA_DB\n    NEWT --> NGINX\n\n    PROMETHEUS --> API\n    PROMETHEUS --> REDIS_EXPORTER\n    PROMETHEUS --> CADVISOR\n    PROMETHEUS --> NODE_EXPORTER\n    GRAFANA --> PROMETHEUS\n    ALERTMANAGER --> PROMETHEUS
"},{"location":"v2/deployment/docker-compose/#core-services","title":"Core Services","text":""},{"location":"v2/deployment/docker-compose/#v2-postgres","title":"v2-postgres","text":"

Purpose: PostgreSQL 16 database for V2 platform (main app + NocoDB metadata)

Configuration:

v2-postgres:\n  image: postgres:16-alpine\n  container_name: changemaker-v2-postgres\n  restart: unless-stopped\n  ports:\n    - \"127.0.0.1:5433:5432\"  # Localhost only\n  environment:\n    POSTGRES_USER: ${V2_POSTGRES_USER:-changemaker}\n    POSTGRES_PASSWORD: ${V2_POSTGRES_PASSWORD}\n    POSTGRES_DB: ${V2_POSTGRES_DB:-changemaker_v2}\n  volumes:\n    - v2-postgres-data:/var/lib/postgresql/data\n    - ./api/prisma/init-nocodb-db.sh:/docker-entrypoint-initdb.d/init-nocodb-db.sh:ro\n  healthcheck:\n    test: [\"CMD-SHELL\", \"pg_isready -U changemaker\"]\n    interval: 10s\n    timeout: 5s\n    retries: 5\n

Key Features: - Alpine image for minimal footprint - init-nocodb-db.sh creates separate nocodb_meta database on first startup - Health check uses pg_isready for fast readiness detection - Port bound to 127.0.0.1 to prevent external access

Volumes: - v2-postgres-data: Persistent PostgreSQL data directory

Dependencies: None (starts first)

"},{"location":"v2/deployment/docker-compose/#redis","title":"redis","text":"

Purpose: Shared Redis instance for sessions, BullMQ job queues, rate limiting, and geocoding cache

Configuration:

redis:\n  image: redis:7-alpine\n  container_name: redis-changemaker\n  command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru --requirepass \"${REDIS_PASSWORD}\"\n  ports:\n    - \"6379:6379\"\n  volumes:\n    - redis-data:/data\n  healthcheck:\n    test: [\"CMD\", \"redis-cli\", \"-a\", \"${REDIS_PASSWORD}\", \"ping\"]\n    interval: 10s\n    timeout: 5s\n    retries: 5\n  deploy:\n    resources:\n      limits:\n        cpus: '1'\n        memory: 512M\n      reservations:\n        cpus: '0.25'\n        memory: 256M\n

Key Features: - Authentication required: --requirepass flag enforces password on all connections - AOF persistence: --appendonly yes writes every command to disk - Memory limits: 512MB max with LRU eviction policy - Resource constraints: Prevents Redis from consuming excessive host resources

Volumes: - redis-data: Persistent AOF log and RDB snapshots

Security Note: As of Security Audit 2025-02-11, Redis authentication is REQUIRED in production. Set a strong REDIS_PASSWORD in .env.

"},{"location":"v2/deployment/docker-compose/#api","title":"api","text":"

Purpose: Unified Express.js API (TypeScript, Prisma ORM)

Configuration:

api:\n  build:\n    context: ./api\n    target: development\n  container_name: changemaker-v2-api\n  restart: unless-stopped\n  ports:\n    - \"${API_PORT:-4000}:4000\"\n    - \"${LISTMONK_PROXY_PORT:-9002}:9002\"\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:4000/api/health\"]\n    interval: 15s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n  environment:\n    - NODE_ENV=${NODE_ENV:-development}\n    - PORT=4000\n    - DATABASE_URL=postgresql://${V2_POSTGRES_USER}:${V2_POSTGRES_PASSWORD}@changemaker-v2-postgres:5432/${V2_POSTGRES_DB}\n    - REDIS_URL=redis://:${REDIS_PASSWORD}@redis-changemaker:6379\n    - JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}\n    - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}\n    # ... 30+ additional env vars (see .env.example)\n  volumes:\n    - ./api:/app\n    - /app/node_modules\n    - ./assets/uploads:/app/uploads\n    - ./mkdocs:/mkdocs:rw\n    - ./data:/data:ro\n    - /var/run/docker.sock:/var/run/docker.sock  # For Docker service management\n  depends_on:\n    v2-postgres:\n      condition: service_healthy\n    redis:\n      condition: service_healthy\n

Key Features: - Waits for PostgreSQL + Redis to be healthy before starting - Mounts source code for live reloading in development - Docker socket access for managing MkDocs/Code Server containers - Health check on /api/health endpoint with 30s startup grace period - Exposes Listmonk proxy on port 9002 (OAuth integration)

Volumes: - ./api:/app: Live code reloading - /app/node_modules: Prevents host node_modules conflicts - ./assets/uploads:/app/uploads: Shared upload directory - ./mkdocs:/mkdocs:rw: MkDocs export target - ./data:/data:ro: NAR import data (read-only) - /var/run/docker.sock: Docker API access

Environment Variables: See Environment Variables for complete reference.

"},{"location":"v2/deployment/docker-compose/#media-api","title":"media-api","text":"

Purpose: Fastify microservice for video library management (Drizzle ORM)

Configuration:

media-api:\n  build:\n    context: ./api\n    dockerfile: Dockerfile.media\n    target: development\n  container_name: changemaker-media-api\n  restart: unless-stopped\n  ports:\n    - \"${MEDIA_API_PORT:-4100}:4100\"\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://127.0.0.1:4100/health\"]\n    interval: 15s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n  environment:\n    - NODE_ENV=${NODE_ENV:-development}\n    - MEDIA_API_PORT=4100\n    - DATABASE_URL=postgresql://...  # Same DB as main API\n    - ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true}\n    - MAX_UPLOAD_SIZE_GB=${MAX_UPLOAD_SIZE_GB:-10}\n  volumes:\n    - ./api:/app\n    - /app/node_modules\n    - ${MEDIA_ROOT:-./media}:/media:ro\n    - ${MEDIA_ROOT:-./media}/local/inbox:/media/local/inbox:rw  # Upload inbox\n  depends_on:\n    v2-postgres:\n      condition: service_healthy\n

Key Features: - Separate Dockerfile (Dockerfile.media) with FFmpeg/FFprobe installed - Shares PostgreSQL database with main API (different ORM) - Media library mounted read-only, inbox writable for uploads - 10GB upload size limit (configurable)

Volumes: - ${MEDIA_ROOT}:/media:ro: Read-only media library - ${MEDIA_ROOT}/local/inbox:/media/local/inbox:rw: RW mount required for video uploads

Important: The inbox directory must have :rw flag; main library stays :ro for security.

"},{"location":"v2/deployment/docker-compose/#admin","title":"admin","text":"

Purpose: React admin GUI (Vite dev server in development, Nginx in production)

Configuration:

admin:\n  build:\n    context: ./admin\n    target: development\n  container_name: changemaker-v2-admin\n  restart: unless-stopped\n  ports:\n    - \"${ADMIN_PORT:-3000}:3000\"\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://127.0.0.1:3000/\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n    start_period: 20s\n  environment:\n    - VITE_API_URL=http://changemaker-v2-api:4000\n    - VITE_MEDIA_API_URL=http://changemaker-media-api:4100\n    - VITE_MKDOCS_URL=http://mkdocs-changemaker:8000\n  volumes:\n    - ./admin:/app\n    - /app/node_modules\n  depends_on:\n    - api\n

Key Features: - Vite environment variables use container hostnames (not localhost) - Health check on root path (Vite dev server responds with HTML) - Live reloading via mounted source code

Environment Variables: - VITE_API_URL: Points to API container (not localhost) - VITE_MEDIA_API_URL: Points to media-api container - VITE_MKDOCS_URL: Points to MkDocs container for iframe embed

Production Build: Swap target: development to target: production and serve static files via Nginx.

"},{"location":"v2/deployment/docker-compose/#nginx","title":"nginx","text":"

Purpose: Reverse proxy with subdomain routing, SSL termination, and iframe embedding support

Configuration:

nginx:\n  build:\n    context: ./nginx\n  container_name: changemaker-v2-nginx\n  restart: unless-stopped\n  ports:\n    - \"${NGINX_HTTP_PORT:-80}:80\"\n    - \"${NGINX_HTTPS_PORT:-443}:443\"\n    - \"8881:8881\"  # NocoDB embed proxy\n    - \"8882:8882\"  # n8n embed proxy\n    - \"8883:8883\"  # Gitea embed proxy\n    - \"8884:8884\"  # MailHog embed proxy\n    - \"8885:8885\"  # Mini QR embed proxy\n  healthcheck:\n    test: [\"CMD\", \"sh\", \"-c\", \"wget -q --spider http://127.0.0.1:80/ && pgrep crond\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n  environment:\n    - PANGOLIN_SITE_ID=${PANGOLIN_SITE_ID:-}\n  volumes:\n    - ./nginx/conf.d:/etc/nginx/conf.d:ro\n    - ./public-web:/usr/share/nginx/public-web:ro\n    - ./configs/pangolin:/etc/pangolin:ro\n  depends_on:\n    - api\n    - admin\n

Key Features: - Subdomain routing: api.cmlite.org, app.cmlite.org, db.cmlite.org, etc. - Embed proxy ports: 888x ports strip X-Frame-Options for iframe embedding - Health check: Validates both HTTP server + cron daemon (for cert renewal) - Read-only configs: Prevents accidental modification

Configuration Files: - nginx.conf: Global settings, gzip, security headers - conf.d/default.conf: Localhost fallback + path-based routing - conf.d/api.conf: API subdomain routing (media endpoints must come before /api/) - conf.d/services.conf: All supporting services + CSP headers

See Nginx Configuration for complete routing details.

"},{"location":"v2/deployment/docker-compose/#nocodb-v2","title":"nocodb-v2","text":"

Purpose: Read-only database browser for V2 schema

Configuration:

nocodb-v2:\n  image: nocodb/nocodb:latest\n  container_name: changemaker-v2-nocodb\n  restart: unless-stopped\n  ports:\n    - \"${NOCODB_V2_PORT:-8091}:8080\"\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:8080/api/v1/health\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n  environment:\n    NC_DB: \"pg://changemaker-v2-postgres:5432?u=${V2_POSTGRES_USER}&p=${V2_POSTGRES_PASSWORD}&d=nocodb_meta\"\n    NC_ADMIN_EMAIL: ${NC_ADMIN_EMAIL:-admin@cmlite.org}\n    NC_ADMIN_PASSWORD: ${NC_ADMIN_PASSWORD}\n    NC_PUBLIC_URL: ${NC_PUBLIC_URL:-http://localhost:8091}\n  volumes:\n    - nocodb-v2-data:/usr/app/data\n  depends_on:\n    v2-postgres:\n      condition: service_healthy\n

Key Features: - Uses separate nocodb_meta database (auto-created by init-nocodb-db.sh) - Health check via NocoDB API endpoint - Read-only access recommended (grant SELECT only in production)

Volumes: - nocodb-v2-data: NocoDB's internal file storage

Access: http://localhost:8091 or http://db.cmlite.org (via subdomain routing)

"},{"location":"v2/deployment/docker-compose/#supporting-services","title":"Supporting Services","text":""},{"location":"v2/deployment/docker-compose/#listmonk-app","title":"listmonk-app","text":"

Purpose: Email marketing platform for newsletters (V2 syncs subscribers via REST API)

Configuration:

listmonk-app:\n  image: listmonk/listmonk:latest\n  container_name: listmonk-app\n  restart: unless-stopped\n  ports:\n    - \"${LISTMONK_PORT:-9001}:9000\"\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:9000/\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n  depends_on:\n    - listmonk-db\n  command: [sh, -c, \"./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''\"]\n  environment:\n    LISTMONK_app__address: 0.0.0.0:9000\n    LISTMONK_db__host: listmonk-db\n    LISTMONK_db__user: ${LISTMONK_DB_USER:-listmonk}\n    LISTMONK_db__password: ${LISTMONK_DB_PASSWORD}\n    LISTMONK_ADMIN_USER: ${LISTMONK_WEB_ADMIN_USER:-admin}\n    LISTMONK_ADMIN_PASSWORD: ${LISTMONK_WEB_ADMIN_PASSWORD}\n  volumes:\n    - ./assets/uploads:/listmonk/uploads:rw\n

Key Features: - Idempotent init: --install --idempotent runs migrations on every start (safe) - Auto-upgrade: --upgrade --yes applies schema upgrades - Shared uploads: Uses same upload directory as main API

Database: Uses separate PostgreSQL 17 instance (listmonk-db)

API Integration: V2 API syncs participants/locations to Listmonk lists via REST API (opt-in via LISTMONK_SYNC_ENABLED=true)

"},{"location":"v2/deployment/docker-compose/#listmonk-db","title":"listmonk-db","text":"

Purpose: PostgreSQL 17 database for Listmonk

Configuration:

listmonk-db:\n  image: postgres:17-alpine\n  container_name: listmonk-db\n  restart: unless-stopped\n  ports:\n    - \"127.0.0.1:5432:5432\"  # Localhost only\n  environment:\n    POSTGRES_USER: ${LISTMONK_DB_USER:-listmonk}\n    POSTGRES_PASSWORD: ${LISTMONK_DB_PASSWORD}\n    POSTGRES_DB: ${LISTMONK_DB_NAME:-listmonk}\n  healthcheck:\n    test: [\"CMD-SHELL\", \"pg_isready -U listmonk\"]\n    interval: 10s\n    timeout: 5s\n    retries: 6\n  volumes:\n    - listmonk-data:/var/lib/postgresql/data\n

Key Features: - Separate PostgreSQL instance (not shared with V2 database) - Port bound to 127.0.0.1 for security

Volumes: - listmonk-data: Persistent Listmonk database

"},{"location":"v2/deployment/docker-compose/#listmonk-init","title":"listmonk-init","text":"

Purpose: One-shot container to create Listmonk API user for V2 integration

Configuration:

listmonk-init:\n  image: postgres:17-alpine\n  container_name: listmonk-init\n  depends_on:\n    listmonk-app:\n      condition: service_started\n  restart: \"no\"  # Runs once and exits\n  environment:\n    PGPASSWORD: ${LISTMONK_DB_PASSWORD}\n    LISTMONK_API_USER: ${LISTMONK_API_USER:-v2-api}\n    LISTMONK_API_TOKEN: ${LISTMONK_API_TOKEN}\n  entrypoint: [\"/bin/sh\", \"-c\"]\n  command:\n    - |\n      # Wait for Listmonk to create tables\n      for i in $(seq 1 30); do\n        if psql -h listmonk-db -U listmonk -d listmonk -c \"SELECT 1 FROM users LIMIT 1\" >/dev/null 2>&1; then\n          break\n        fi\n        sleep 2\n      done\n\n      # Upsert API user\n      psql -h listmonk-db -U listmonk -d listmonk -q <<SQL\n      INSERT INTO users (username, password, password_login, email, name, type, user_role_id, status)\n      VALUES ('$LISTMONK_API_USER', '$LISTMONK_API_TOKEN', true, '$LISTMONK_API_USER@api.internal', '$LISTMONK_API_USER', 'api', 1, 'enabled')\n      ON CONFLICT (username) DO UPDATE SET password = EXCLUDED.password, status = 'enabled';\n      SQL\n

Key Features: - Idempotent: Safe to run multiple times (upserts API user) - Auto-configuration: Also configures SMTP providers (MailHog + production) - Exit on completion: restart: \"no\" prevents restart after success

Important: Listmonk API users store tokens as plaintext (not bcrypt), so direct SQL upsert works.

"},{"location":"v2/deployment/docker-compose/#gitea-app","title":"gitea-app","text":"

Purpose: Self-hosted Git repository hosting

Configuration:

gitea-app:\n  image: gitea/gitea:1.23.7\n  container_name: gitea-changemaker\n  healthcheck:\n    test: [\"CMD\", \"curl\", \"-f\", \"http://localhost:3000/\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n  environment:\n    - GITEA__database__DB_TYPE=mysql\n    - GITEA__database__HOST=gitea-db:3306\n    - GITEA__server__ROOT_URL=${GITEA_ROOT_URL}\n    - GITEA__server__X_FRAME_OPTIONS=  # Allow iframe embedding\n    - GITEA__server__LFS_MAX_FILE_SIZE=1024  # 1GB LFS\n  ports:\n    - \"${GITEA_WEB_PORT:-3030}:3000\"\n    - \"${GITEA_SSH_PORT:-2222}:22\"\n  volumes:\n    - gitea-data:/data\n  depends_on:\n    - gitea-db\n

Key Features: - MySQL backend: Uses separate MySQL 8 container - LFS support: 1GB max file size for large binaries - SSH access: Port 2222 for Git push/pull - Iframe embedding: X_FRAME_OPTIONS disabled for admin iframe

Health Check: Uses curl (Debian-based image) not wget

Volumes: - gitea-data: Git repositories + attachments

"},{"location":"v2/deployment/docker-compose/#n8n","title":"n8n","text":"

Purpose: Workflow automation platform

Configuration:

n8n:\n  image: docker.n8n.io/n8nio/n8n\n  container_name: n8n-changemaker\n  restart: unless-stopped\n  ports:\n    - \"${N8N_PORT:-5678}:5678\"\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:5678/healthz\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n  environment:\n    - N8N_HOST=${N8N_HOST:-n8n.cmlite.org}\n    - N8N_PROTOCOL=https\n    - WEBHOOK_URL=https://${N8N_HOST}/\n    - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}\n    - N8N_DEFAULT_USER_EMAIL=${N8N_USER_EMAIL}\n    - N8N_DEFAULT_USER_PASSWORD=${N8N_USER_PASSWORD}\n  volumes:\n    - n8n-data:/home/node/.n8n\n    - ./local-files:/files\n

Key Features: - HTTPS required: N8N_PROTOCOL=https for webhook security - User management: Creates default admin user on first start - File access: /files directory for workflow file operations

Health Check: /healthz endpoint (Alpine image uses wget)

Volumes: - n8n-data: Workflow definitions + credentials - ./local-files:/files: Shared file directory for workflows

"},{"location":"v2/deployment/docker-compose/#mkdocs","title":"mkdocs","text":"

Purpose: Live documentation preview server (Material theme)

Configuration:

mkdocs:\n  image: squidfunk/mkdocs-material\n  container_name: mkdocs-changemaker\n  volumes:\n    - ./mkdocs:/docs:rw\n    - ./assets/images:/docs/assets/images:rw\n  user: \"${USER_ID:-1000}:${GROUP_ID:-1000}\"\n  ports:\n    - \"${MKDOCS_PORT:-4003}:8000\"\n  environment:\n    - SITE_URL=${BASE_DOMAIN:-https://cmlite.org}\n  command: serve --dev-addr=0.0.0.0:8000 --watch-theme --livereload\n  restart: unless-stopped\n

Key Features: - Live reloading: --livereload watches for file changes - User mapping: Runs as host user to prevent permission issues - Port 4003: Changed from 4000 (conflicted with API in V1)

Volumes: - ./mkdocs:/docs:rw: Documentation source (writable for MkDocs export) - ./assets/images:/docs/assets/images:rw: Shared image directory

Access: http://localhost:4003 or http://docs.cmlite.org (via subdomain routing)

"},{"location":"v2/deployment/docker-compose/#code-server","title":"code-server","text":"

Purpose: VS Code in the browser for documentation editing

Configuration:

code-server:\n  build:\n    context: .\n    dockerfile: Dockerfile.code-server\n  container_name: code-server-changemaker\n  command: /home/coder/project\n  environment:\n    - DOCKER_USER=${USER_NAME:-coder}\n  user: \"${USER_ID:-1000}:${GROUP_ID:-1000}\"\n  volumes:\n    - ./configs/code-server/.config:/home/coder/.config\n    - ./configs/code-server/.local:/home/coder/.local\n    - .:/home/coder/project\n  ports:\n    - \"${CODE_SERVER_PORT:-8888}:8080\"\n  restart: unless-stopped\n

Key Features: - User mapping: Runs as host user (prevents permission conflicts) - Project mount: Entire repository mounted at /home/coder/project - Persistent config: .config and .local directories preserved

Access: http://localhost:8888 or http://code.cmlite.org (via subdomain routing)

"},{"location":"v2/deployment/docker-compose/#mailhog","title":"mailhog","text":"

Purpose: Email capture for development/testing

Configuration:

mailhog:\n  image: mailhog/mailhog:latest\n  container_name: mailhog-changemaker\n  ports:\n    - \"${MAILHOG_WEB_PORT:-8025}:8025\"\n    # SMTP port 1025 only exposed on Docker network\n  restart: unless-stopped\n  logging:\n    driver: \"json-file\"\n    options:\n      max-size: \"5m\"\n      max-file: \"2\"\n

Key Features: - SMTP on port 1025: Accessible only from Docker network (not exposed to host) - Web UI on port 8025: View captured emails - Log rotation: 5MB max size, 2 files

Usage: Set EMAIL_TEST_MODE=true in .env to route all emails to MailHog

Access: http://localhost:8025 or http://mail.cmlite.org (via subdomain routing)

"},{"location":"v2/deployment/docker-compose/#mini-qr","title":"mini-qr","text":"

Purpose: QR code generation service (used by walk sheets + cut exports)

Configuration:

mini-qr:\n  image: ghcr.io/lyqht/mini-qr:latest\n  container_name: mini-qr\n  ports:\n    - \"${MINI_QR_PORT:-8089}:8080\"\n  restart: unless-stopped\n

Key Features: - Stateless: No volumes or persistent data - Lightweight: Alpine-based image

API Integration: V2 API has dedicated /api/qr routes for direct PNG generation; mini-qr used for admin iframe

Access: http://localhost:8089 or http://qr.cmlite.org (via subdomain routing)

"},{"location":"v2/deployment/docker-compose/#homepage","title":"homepage","text":"

Purpose: Service dashboard with container status

Configuration:

homepage:\n  image: ghcr.io/gethomepage/homepage:latest\n  container_name: homepage-changemaker\n  ports:\n    - \"${HOMEPAGE_PORT:-3010}:3000\"\n  volumes:\n    - ./configs/homepage:/app/config\n    - ./assets/icons:/app/public/icons\n    - ./assets/images:/app/public/images\n    - /var/run/docker.sock:/var/run/docker.sock\n  environment:\n    - PUID=${USER_ID:-1000}\n    - PGID=${DOCKER_GROUP_ID:-984}\n    - HOMEPAGE_ALLOWED_HOSTS=*\n  restart: unless-stopped\n

Key Features: - Docker socket access: Reads container status - User mapping: Runs as host user with Docker group - Custom dashboard: Configure in configs/homepage/

Access: http://localhost:3010 or http://home.cmlite.org (via subdomain routing)

"},{"location":"v2/deployment/docker-compose/#media-services","title":"Media Services","text":""},{"location":"v2/deployment/docker-compose/#public-media","title":"public-media","text":"

Purpose: Public video gallery frontend (React production build)

Configuration:

public-media:\n  build:\n    context: ./public-media\n  container_name: changemaker-public-media\n  restart: unless-stopped\n  ports:\n    - \"${PUBLIC_MEDIA_PORT:-3100}:80\"\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://127.0.0.1:80/\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n    start_period: 10s\n  depends_on:\n    - api\n    - media-api\n

Key Features: - Static build: React app served by Nginx (not Vite dev server) - Fast startup: 10s start period (static files load quickly)

Access: http://localhost:3100 or /gallery/ path via main Nginx

"},{"location":"v2/deployment/docker-compose/#tunnel-services","title":"Tunnel Services","text":""},{"location":"v2/deployment/docker-compose/#newt","title":"newt","text":"

Purpose: Pangolin tunnel connector (replaces Cloudflare Tunnel)

Configuration:

newt:\n  image: fosrl/newt\n  container_name: newt-changemaker\n  restart: unless-stopped\n  environment:\n    - PANGOLIN_ENDPOINT=${PANGOLIN_ENDPOINT}\n    - NEWT_ID=${PANGOLIN_NEWT_ID}\n    - NEWT_SECRET=${PANGOLIN_NEWT_SECRET}\n  depends_on:\n    - nginx\n

Key Features: - Self-hosted: Connects to Pangolin server at api.bnkserve.org - Nginx dependency: All traffic routes through nginx:80 - Auto-reconnect: restart: unless-stopped handles connection drops

Setup: Use admin PangolinPage.tsx wizard to configure org \u2192 site \u2192 endpoint \u2192 resource

See Tunneling for complete setup guide.

"},{"location":"v2/deployment/docker-compose/#monitoring-services-profile-monitoring","title":"Monitoring Services (profile: monitoring)","text":""},{"location":"v2/deployment/docker-compose/#prometheus","title":"prometheus","text":"

Purpose: Metrics collection and alerting

Configuration:

prometheus:\n  image: prom/prometheus:latest\n  container_name: prometheus-changemaker\n  command:\n    - '--config.file=/etc/prometheus/prometheus.yml'\n    - '--storage.tsdb.path=/prometheus'\n    - '--storage.tsdb.retention.time=30d'\n  ports:\n    - \"${PROMETHEUS_PORT:-9090}:9090\"\n  volumes:\n    - ./configs/prometheus:/etc/prometheus\n    - prometheus-data:/prometheus\n  restart: always\n  profiles:\n    - monitoring\n

Key Features: - 30-day retention: --storage.tsdb.retention.time=30d - Custom metrics: 12 cm_* metrics from API - Alert rules: alerts.yml defines 12+ alert conditions

Scrape Targets: - changemaker-v2-api:4000/api/metrics (10s interval) - redis-exporter:9121 (15s interval) - cadvisor:8080 (15s interval) - node-exporter:9100 (15s interval)

Access: http://localhost:9090

See Monitoring Stack for complete configuration.

"},{"location":"v2/deployment/docker-compose/#grafana","title":"grafana","text":"

Purpose: Metrics visualization

Configuration:

grafana:\n  image: grafana/grafana:latest\n  container_name: grafana-changemaker\n  ports:\n    - \"${GRAFANA_PORT:-3001}:3000\"\n  environment:\n    - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}\n    - GF_USERS_ALLOW_SIGN_UP=false\n    - GF_SECURITY_ALLOW_EMBEDDING=true  # For admin iframe\n  volumes:\n    - grafana-data:/var/lib/grafana\n    - ./configs/grafana:/etc/grafana/provisioning\n  restart: always\n  depends_on:\n    - prometheus\n  profiles:\n    - monitoring\n

Key Features: - Auto-provisioning: Dashboards from configs/grafana/ auto-load on startup - 3 dashboards: Application Overview, API Performance, System Health - Prometheus datasource: Auto-configured via datasources.yml

Access: http://localhost:3001 (admin/admin default)

"},{"location":"v2/deployment/docker-compose/#cadvisor","title":"cadvisor","text":"

Purpose: Container resource metrics

Configuration:

cadvisor:\n  image: gcr.io/cadvisor/cadvisor:latest\n  container_name: cadvisor-changemaker\n  ports:\n    - \"${CADVISOR_PORT:-8080}:8080\"\n  volumes:\n    - /:/rootfs:ro\n    - /var/run:/var/run:ro\n    - /sys:/sys:ro\n    - /var/lib/docker/:/var/lib/docker:ro\n    - /dev/disk/:/dev/disk:ro\n  privileged: true\n  devices:\n    - /dev/kmsg\n  restart: always\n  profiles:\n    - monitoring\n

Key Features: - Privileged mode: Required for full system access - Host filesystem: Read-only mounts for metrics collection

Access: http://localhost:8080

"},{"location":"v2/deployment/docker-compose/#node-exporter","title":"node-exporter","text":"

Purpose: Host system metrics (CPU, memory, disk, network)

Configuration:

node-exporter:\n  image: prom/node-exporter:latest\n  container_name: node-exporter-changemaker\n  ports:\n    - \"${NODE_EXPORTER_PORT:-9100}:9100\"\n  command:\n    - '--path.rootfs=/host'\n    - '--path.procfs=/host/proc'\n    - '--path.sysfs=/host/sys'\n    - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'\n  volumes:\n    - /proc:/host/proc:ro\n    - /sys:/host/sys:ro\n    - /:/rootfs:ro\n  restart: always\n  profiles:\n    - monitoring\n

Key Features: - Host metrics: CPU, memory, disk, network from host (not container) - Filesystem filters: Excludes virtual filesystems

Access: http://localhost:9100/metrics

"},{"location":"v2/deployment/docker-compose/#redis-exporter","title":"redis-exporter","text":"

Purpose: Redis metrics (memory, commands, connections)

Configuration:

redis-exporter:\n  image: oliver006/redis_exporter:latest\n  container_name: redis-exporter-changemaker\n  ports:\n    - \"${REDIS_EXPORTER_PORT:-9121}:9121\"\n  environment:\n    - REDIS_ADDR=redis:6379\n    - REDIS_PASSWORD=${REDIS_PASSWORD}  # Required for authenticated Redis\n  restart: always\n  depends_on:\n    - redis\n  profiles:\n    - monitoring\n

Key Features: - Authenticated connection: Uses REDIS_PASSWORD env var - Memory metrics: Tracks Redis memory usage

Access: http://localhost:9121/metrics

"},{"location":"v2/deployment/docker-compose/#alertmanager","title":"alertmanager","text":"

Purpose: Alert routing and notification

Configuration:

alertmanager:\n  image: prom/alertmanager:latest\n  container_name: alertmanager-changemaker\n  ports:\n    - \"${ALERTMANAGER_PORT:-9093}:9093\"\n  volumes:\n    - ./configs/alertmanager:/etc/alertmanager\n    - alertmanager-data:/alertmanager\n  command:\n    - '--config.file=/etc/alertmanager/alertmanager.yml'\n    - '--storage.path=/alertmanager'\n  restart: always\n  profiles:\n    - monitoring\n

Key Features: - Alert grouping: Prevents notification spam - Multiple receivers: Email, Slack, webhook, Gotify

Configuration: Edit configs/alertmanager/alertmanager.yml

Access: http://localhost:9093

"},{"location":"v2/deployment/docker-compose/#gotify","title":"gotify","text":"

Purpose: Push notification server (optional alert receiver)

Configuration:

gotify:\n  image: gotify/server:latest\n  container_name: gotify-changemaker\n  ports:\n    - \"${GOTIFY_PORT:-8889}:80\"\n  environment:\n    - GOTIFY_DEFAULTUSER_NAME=${GOTIFY_ADMIN_USER:-admin}\n    - GOTIFY_DEFAULTUSER_PASS=${GOTIFY_ADMIN_PASSWORD:-admin}\n  volumes:\n    - gotify-data:/app/data\n  restart: always\n  profiles:\n    - monitoring\n

Key Features: - Push notifications: Mobile app support (iOS/Android) - Webhook receiver: Integrates with Alertmanager

Access: http://localhost:8889

"},{"location":"v2/deployment/docker-compose/#networks-volumes","title":"Networks & Volumes","text":""},{"location":"v2/deployment/docker-compose/#networks","title":"Networks","text":"

changemaker-lite: Bridge network shared by all services

networks:\n  changemaker-lite:\n    driver: bridge\n

Features: - Automatic DNS: Containers resolve each other by name (e.g., changemaker-v2-api:4000) - Isolation: No external network access unless ports explicitly exposed - Service discovery: Docker's internal DNS server (127.0.0.11)

"},{"location":"v2/deployment/docker-compose/#volumes","title":"Volumes","text":"

Named volumes (Docker-managed, persistent across container recreation):

Volume Purpose Size Estimate v2-postgres-data V2 PostgreSQL database 1-10GB (depends on data) nocodb-v2-data NocoDB metadata + uploads 100MB-1GB redis-data Redis AOF log + RDB snapshots 50-500MB listmonk-data Listmonk PostgreSQL database 100MB-5GB n8n-data n8n workflows + credentials 10-100MB gitea-data Git repositories + attachments 1-50GB mysql-data Gitea MySQL database 100MB-2GB prometheus-data Prometheus TSDB (30 days) 1-5GB grafana-data Grafana dashboards + config 10-100MB alertmanager-data Alert state + silences 1-10MB gotify-data Gotify messages + apps 10-100MB

Bind mounts (host directories):

Bind Mount Container Path Purpose Permissions ./api /app API source code rw ./admin /app Admin source code rw ./assets/uploads /app/uploads, /listmonk/uploads Shared uploads rw ./mkdocs /docs, /mkdocs Documentation source rw ./data /data NAR import data ro ./nginx/conf.d /etc/nginx/conf.d Nginx config ro ./configs/prometheus /etc/prometheus Prometheus config ro ./configs/grafana /etc/grafana/provisioning Grafana config ro /var/run/docker.sock /var/run/docker.sock Docker API rw

Important: Media library requires special mount:

- ${MEDIA_ROOT:-./media}:/media:ro              # Main library (read-only)\n- ${MEDIA_ROOT:-./media}/local/inbox:/media/local/inbox:rw  # Upload inbox (writable)\n

"},{"location":"v2/deployment/docker-compose/#starting-services","title":"Starting Services","text":""},{"location":"v2/deployment/docker-compose/#basic-commands","title":"Basic Commands","text":"

Start all core services:

docker compose up -d\n

Start with monitoring stack:

docker compose --profile monitoring up -d\n

Start specific service:

docker compose up -d api\n

Start with rebuild:

docker compose up -d --build api admin\n

Stop all services:

docker compose down\n

Stop and remove volumes (\u26a0\ufe0f destroys all data):

docker compose down -v\n

"},{"location":"v2/deployment/docker-compose/#development-workflow","title":"Development Workflow","text":"

1. Initial setup (first time only):

# Start core services\ndocker compose up -d v2-postgres redis api admin\n\n# Wait for API to be healthy\ndocker compose ps api  # Check status\n\n# Run migrations\ndocker compose exec api npx prisma migrate deploy\n\n# Seed database\ndocker compose exec api npx prisma db seed\n

2. Daily development:

# Start services\ndocker compose up -d v2-postgres redis api admin\n\n# View logs (live tail)\ndocker compose logs -f api\n\n# Restart single service\ndocker compose restart api\n\n# Check health status\ndocker compose ps\n

3. Full stack with monitoring:

# Start everything\ndocker compose --profile monitoring up -d\n\n# Check Prometheus targets\ncurl http://localhost:9090/api/v1/targets\n\n# View Grafana dashboards\nopen http://localhost:3001\n

"},{"location":"v2/deployment/docker-compose/#log-management","title":"Log Management","text":"

View logs:

# All services (last 50 lines)\ndocker compose logs --tail=50\n\n# Specific service (live tail)\ndocker compose logs -f api\n\n# Multiple services\ndocker compose logs -f api media-api\n\n# With timestamps\ndocker compose logs -f --timestamps api\n\n# Since timestamp\ndocker compose logs --since 2024-01-01T00:00:00 api\n

Log rotation: Configured in docker-compose.yml for Redis + MailHog:

logging:\n  driver: \"json-file\"\n  options:\n    max-size: \"5m\"\n    max-file: \"2\"\n

"},{"location":"v2/deployment/docker-compose/#health-checks","title":"Health Checks","text":"

Check service health:

# All services (shows health status)\ndocker compose ps\n\n# Filter unhealthy services\ndocker compose ps | grep unhealthy\n\n# Inspect health check details\ndocker inspect changemaker-v2-api | jq '.[0].State.Health'\n

Services with health checks: - api: wget http://localhost:4000/api/health (30s start period) - media-api: wget http://127.0.0.1:4100/health (30s start period) - admin: wget http://127.0.0.1:3000/ (20s start period) - v2-postgres: pg_isready -U changemaker (5 retries) - redis: redis-cli -a ${REDIS_PASSWORD} ping (5 retries) - gitea-app: curl http://localhost:3000/ (30s start period) - n8n: wget http://localhost:5678/healthz (30s start period)

Dependency chains (via depends_on with condition: service_healthy): - api waits for v2-postgres + redis - media-api waits for v2-postgres - nocodb-v2 waits for v2-postgres

See Health Checks for detailed configuration.

"},{"location":"v2/deployment/docker-compose/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/docker-compose/#port-conflicts","title":"Port Conflicts","text":"

Problem: Error: bind: address already in use

Solution:

# Find process using port\nsudo lsof -i :4000\nsudo netstat -tulpn | grep :4000\n\n# Change port in .env\necho \"API_PORT=4002\" >> .env\n\n# Restart service\ndocker compose up -d api\n

Common conflicts: - Port 3000: Homepage, Grafana, admin (set ADMIN_PORT=3005) - Port 4000: API, MkDocs v1 (set MKDOCS_PORT=4003) - Port 5432: Listmonk DB, system PostgreSQL (bind to 127.0.0.1 in compose file)

"},{"location":"v2/deployment/docker-compose/#volume-permission-issues","title":"Volume Permission Issues","text":"

Problem: EACCES: permission denied or mkdir: cannot create directory

Cause: Container user mismatch with host filesystem

Solution:

# Fix ownership (run on host)\nsudo chown -R $USER:$USER ./api ./admin ./mkdocs ./assets\n\n# Set USER_ID/GROUP_ID in .env\nid -u  # Get your UID\nid -g  # Get your GID\necho \"USER_ID=$(id -u)\" >> .env\necho \"GROUP_ID=$(id -g)\" >> .env\n\n# Recreate containers\ndocker compose up -d --force-recreate\n

Services using user mapping: - mkdocs: user: \"${USER_ID}:${GROUP_ID}\" - code-server: user: \"${USER_ID}:${GROUP_ID}\" - homepage: PUID=${USER_ID}, PGID=${DOCKER_GROUP_ID}

"},{"location":"v2/deployment/docker-compose/#network-issues","title":"Network Issues","text":"

Problem: Containers can't communicate (e.g., API can't reach Redis)

Solution:

# Verify network exists\ndocker network ls | grep changemaker-lite\n\n# Inspect network\ndocker network inspect changemaker-lite\n\n# Check container connectivity\ndocker compose exec api ping redis-changemaker\n\n# Recreate network\ndocker compose down\ndocker compose up -d\n

DNS resolution: Containers use Docker's internal DNS (127.0.0.11). Reference services by container name: - \u2705 redis-changemaker:6379 - \u274c localhost:6379 (only works if port exposed to host)

"},{"location":"v2/deployment/docker-compose/#database-migration-failures","title":"Database Migration Failures","text":"

Problem: prisma migrate deploy fails with \"relation already exists\"

Solution:

# Reset database (\u26a0\ufe0f destroys data)\ndocker compose exec api npx prisma migrate reset --force\n\n# Or: Fix manually\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2\n\n# Check migration status\ndocker compose exec api npx prisma migrate status\n\n# Force resolve migration\ndocker compose exec api npx prisma migrate resolve --applied \"20240101000000_init\"\n

"},{"location":"v2/deployment/docker-compose/#container-crashes-restart-loops","title":"Container Crashes / Restart Loops","text":"

Problem: Container repeatedly restarting

Diagnosis:

# Check logs for crash reason\ndocker compose logs --tail=100 api\n\n# Check exit code\ndocker inspect changemaker-v2-api | jq '.[0].State'\n\n# Check resource limits\ndocker stats changemaker-v2-api\n

Common causes: - Missing env vars: Check .env file for required secrets - Health check failing: Inspect health check logs - Out of memory: Increase Docker memory limit or add resource constraints - Port binding failure: Check for port conflicts

Fix:

# Restart with fresh logs\ndocker compose up -d --force-recreate api\n\n# Check health\ndocker compose ps api\n

"},{"location":"v2/deployment/docker-compose/#monitoring-stack-not-starting","title":"Monitoring Stack Not Starting","text":"

Problem: Prometheus/Grafana containers missing

Cause: Monitoring services behind profiles: [monitoring]

Solution:

# Start with monitoring profile\ndocker compose --profile monitoring up -d\n\n# Or: Explicitly start monitoring services\ndocker compose up -d prometheus grafana\n

"},{"location":"v2/deployment/docker-compose/#media-upload-failures","title":"Media Upload Failures","text":"

Problem: Video uploads fail with EACCES or timeout

Diagnosis:

# Check media-api logs\ndocker compose logs -f media-api\n\n# Verify inbox permissions\nls -la ./media/local/inbox\n\n# Check disk space\ndf -h\n

Solution:

# Ensure inbox is writable\nchmod 755 ./media/local/inbox\n\n# Verify RW mount in docker-compose.yml\ngrep \"inbox:rw\" docker-compose.yml\n\n# Recreate container\ndocker compose up -d --force-recreate media-api\n

Important: Inbox must have :rw flag; main library stays :ro.

"},{"location":"v2/deployment/docker-compose/#production-deployment","title":"Production Deployment","text":""},{"location":"v2/deployment/docker-compose/#resource-limits","title":"Resource Limits","text":"

Production recommendations:

# Add to services in docker-compose.yml\ndeploy:\n  resources:\n    limits:\n      cpus: '2'\n      memory: 2G\n    reservations:\n      cpus: '0.5'\n      memory: 512M\n

Recommended limits: - api: 2 CPU, 2GB RAM - media-api: 2 CPU, 2GB RAM (for FFprobe) - v2-postgres: 2 CPU, 4GB RAM - redis: 1 CPU, 512MB RAM (already set) - listmonk-app: 1 CPU, 1GB RAM - grafana: 1 CPU, 512MB RAM

"},{"location":"v2/deployment/docker-compose/#healthcheck-tuning","title":"Healthcheck Tuning","text":"

Production healthcheck configuration:

healthcheck:\n  interval: 30s      # Check every 30s (default: 15s)\n  timeout: 10s       # Allow 10s for response (default: 5s)\n  retries: 5         # 5 failures before unhealthy (default: 3)\n  start_period: 60s  # 60s grace period on startup (default: 30s)\n

Rationale: - Longer intervals reduce overhead - Higher retries prevent false positives - Longer start periods for slow database migrations

"},{"location":"v2/deployment/docker-compose/#log-management_1","title":"Log Management","text":"

Production logging configuration:

# Add to all services\nlogging:\n  driver: \"json-file\"\n  options:\n    max-size: \"10m\"\n    max-file: \"5\"\n

Alternative: Use centralized logging (e.g., Loki + Promtail):

logging:\n  driver: \"loki\"\n  options:\n    loki-url: \"http://loki:3100/loki/api/v1/push\"\n

"},{"location":"v2/deployment/docker-compose/#restart-policies","title":"Restart Policies","text":"

Production restart policies: - restart: always \u2014 For critical services (db, redis, api) - restart: unless-stopped \u2014 For most services (respects manual stops) - restart: on-failure \u2014 For optional services (monitoring)

Current configuration: Most services use unless-stopped (allows manual shutdown).

"},{"location":"v2/deployment/docker-compose/#backup-strategy","title":"Backup Strategy","text":"

Automated backups (via cron):

# Add to crontab\n0 2 * * * /home/user/changemaker.lite/scripts/backup.sh --s3 >> /var/log/changemaker-backup.log 2>&1\n

What gets backed up: - V2 PostgreSQL database (pg_dump) - Listmonk PostgreSQL database (pg_dump) - Uploads directory (tar.gz)

See Backup & Restore for complete procedures.

"},{"location":"v2/deployment/docker-compose/#security-hardening","title":"Security Hardening","text":"

Production checklist: - [ ] Change all default passwords in .env - [ ] Set strong REDIS_PASSWORD (required since Security Audit 2025-02-11) - [ ] Bind PostgreSQL ports to 127.0.0.1 (not 0.0.0.0) - [ ] Enable SSL/TLS via Nginx (see SSL/TLS) - [ ] Set ENCRYPTION_KEY (must differ from JWT secrets) - [ ] Disable EMAIL_TEST_MODE (use real SMTP) - [ ] Set NODE_ENV=production - [ ] Review Nginx security headers (CSP, HSTS, Permissions-Policy) - [ ] Restrict NocoDB to read-only access (revoke INSERT/UPDATE/DELETE) - [ ] Enable Prometheus scraping authentication (basic auth)

"},{"location":"v2/deployment/docker-compose/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/deployment/environment-variables/","title":"Environment Variables Reference","text":""},{"location":"v2/deployment/environment-variables/#overview","title":"Overview","text":"

Changemaker Lite V2 uses over 100 environment variables to configure services, credentials, and feature flags. This document provides a complete reference organized by functional area.

Configuration File: .env (never committed to Git)

Template: .env.example (committed, safe to share)

Validation: api/src/config/env.ts (Zod schema validates all variables on startup)

"},{"location":"v2/deployment/environment-variables/#quick-start","title":"Quick Start","text":""},{"location":"v2/deployment/environment-variables/#initial-setup","title":"Initial Setup","text":"
# Copy template\ncp .env.example .env\n\n# Generate secrets\nopenssl rand -hex 32  # For JWT_ACCESS_SECRET\nopenssl rand -hex 32  # For JWT_REFRESH_SECRET\nopenssl rand -hex 32  # For ENCRYPTION_KEY (must differ from JWT secrets!)\nopenssl rand -hex 16  # For LISTMONK_API_TOKEN\n\n# Edit .env\nnano .env\n
"},{"location":"v2/deployment/environment-variables/#minimal-required-variables","title":"Minimal Required Variables","text":"

Must set before first start:

V2_POSTGRES_PASSWORD=<strong-password>\nREDIS_PASSWORD=<strong-password>\nJWT_ACCESS_SECRET=<openssl-rand-hex-32>\nJWT_REFRESH_SECRET=<openssl-rand-hex-32>\nENCRYPTION_KEY=<openssl-rand-hex-32>  # Production only\n

All other variables have safe defaults for development.

"},{"location":"v2/deployment/environment-variables/#general-configuration","title":"General Configuration","text":"Variable Default Required Description NODE_ENV development No Environment mode (development | production) DOMAIN cmlite.org No Base domain for subdomain routing USER_ID 1000 No Host user ID for volume permissions GROUP_ID 1000 No Host group ID for volume permissions DOCKER_GROUP_ID 984 No Docker group ID (for homepage container)

Usage:

NODE_ENV=production docker compose up -d\n

"},{"location":"v2/deployment/environment-variables/#v2-postgresql","title":"V2 PostgreSQL","text":"Variable Default Required Description V2_POSTGRES_USER changemaker No PostgreSQL username V2_POSTGRES_PASSWORD CHANGE_ME_STRONG_PASSWORD Yes PostgreSQL password V2_POSTGRES_DB changemaker_v2 No Database name V2_POSTGRES_PORT 5433 No Host port (container always 5432)

Connection String (auto-generated in docker-compose.yml):

postgresql://changemaker:PASSWORD@changemaker-v2-postgres:5432/changemaker_v2\n

Port Binding: 127.0.0.1:5433:5432 (localhost only for security)

Important: Change V2_POSTGRES_PASSWORD before production deployment.

"},{"location":"v2/deployment/environment-variables/#jwt-authentication","title":"JWT Authentication","text":"Variable Default Required Description JWT_ACCESS_SECRET GENERATE_WITH_openssl_rand_hex_32 Yes Access token secret (15min lifespan) JWT_REFRESH_SECRET GENERATE_WITH_openssl_rand_hex_32 Yes Refresh token secret (7 day lifespan) JWT_ACCESS_EXPIRY 15m No Access token expiration (15m, 1h, etc.) JWT_REFRESH_EXPIRY 7d No Refresh token expiration (7d, 30d, etc.) ENCRYPTION_KEY GENERATE_WITH_openssl_rand_hex_32 Yes (prod) DB encryption key for SMTP passwords, etc.

Security Requirements (enforced by Zod schema): - JWT_ACCESS_SECRET must be 32+ characters - JWT_REFRESH_SECRET must be 32+ characters - ENCRYPTION_KEY must be 32+ characters and differ from JWT secrets

Generation:

export JWT_ACCESS_SECRET=$(openssl rand -hex 32)\nexport JWT_REFRESH_SECRET=$(openssl rand -hex 32)\nexport ENCRYPTION_KEY=$(openssl rand -hex 32)\necho \"JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}\" >> .env\necho \"JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}\" >> .env\necho \"ENCRYPTION_KEY=${ENCRYPTION_KEY}\" >> .env\n

Production Note: ENCRYPTION_KEY required in production (dev mode allows empty for testing).

"},{"location":"v2/deployment/environment-variables/#redis","title":"Redis","text":"Variable Default Required Description REDIS_PASSWORD CHANGE_ME_REDIS_PASSWORD Yes Redis authentication password REDIS_URL redis://:PASSWORD@redis-changemaker:6379 No Full connection URL (auto-generated)

Format: redis://[:<password>@]<host>:<port>[/<db>]

Example:

REDIS_PASSWORD=mySecurePassword123\nREDIS_URL=redis://:mySecurePassword123@redis-changemaker:6379\n

Security Note: As of Security Audit 2025-02-11, Redis requires authentication in production.

Docker Command (in docker-compose.yml):

command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru --requirepass \"${REDIS_PASSWORD}\"\n

"},{"location":"v2/deployment/environment-variables/#api-configuration","title":"API Configuration","text":"Variable Default Required Description API_PORT 4000 No Express API port (host) API_URL http://localhost:4000 No Public API URL (for emails, OAuth redirects) CORS_ORIGINS http://localhost:3000,http://localhost No Allowed CORS origins (comma-separated)

Production Example:

API_PORT=4000\nAPI_URL=https://api.cmlite.org\nCORS_ORIGINS=https://app.cmlite.org,https://cmlite.org\n

CORS Note: List all frontend origins (admin, public site, media gallery).

"},{"location":"v2/deployment/environment-variables/#admin-gui","title":"Admin GUI","text":"Variable Default Required Description ADMIN_PORT 3000 No Admin GUI port (host) ADMIN_URL http://localhost:3000 No Public admin URL VITE_API_URL http://changemaker-v2-api:4000 No API URL for Vite proxy (Docker internal) VITE_MEDIA_API_URL http://changemaker-media-api:4100 No Media API URL for Vite proxy VITE_MKDOCS_URL http://mkdocs-changemaker:8000 No MkDocs URL for iframe embed

Development vs Production:

Development (Docker):

VITE_API_URL=http://changemaker-v2-api:4000  # Container name\nVITE_MEDIA_API_URL=http://changemaker-media-api:4100\n

Development (local):

VITE_API_URL=http://localhost:4000  # Localhost\nVITE_MEDIA_API_URL=http://localhost:4100\n

Production: Vite build embeds these URLs at build time.

"},{"location":"v2/deployment/environment-variables/#nginx","title":"Nginx","text":"Variable Default Required Description NGINX_HTTP_PORT 80 No HTTP port NGINX_HTTPS_PORT 443 No HTTPS port

Port Mapping (docker-compose.yml):

nginx:\n  ports:\n    - \"80:80\"\n    - \"443:443\"\n    - \"8881:8881\"  # NocoDB embed proxy\n    - \"8882:8882\"  # n8n embed proxy\n    - \"8883:8883\"  # Gitea embed proxy\n    - \"8884:8884\"  # MailHog embed proxy\n    - \"8885:8885\"  # Mini QR embed proxy\n

Custom Ports (if 80/443 occupied):

NGINX_HTTP_PORT=8080\nNGINX_HTTPS_PORT=8443\n

"},{"location":"v2/deployment/environment-variables/#smtp-email","title":"SMTP / Email","text":"Variable Default Required Description SMTP_HOST mailhog-changemaker No SMTP server hostname SMTP_PORT 1025 No SMTP server port SMTP_USER `` No SMTP username (empty for MailHog) SMTP_PASS `` No SMTP password SMTP_FROM noreply@cmlite.org No Default sender email SMTP_FROM_NAME Changemaker Lite No Default sender name EMAIL_TEST_MODE true No Route all emails to MailHog (dev mode) TEST_EMAIL_RECIPIENT admin@cmlite.org No Override recipient in test mode

Development (MailHog):

SMTP_HOST=mailhog-changemaker\nSMTP_PORT=1025\nSMTP_USER=\nSMTP_PASS=\nEMAIL_TEST_MODE=true\n

Production (e.g., ProtonMail):

SMTP_HOST=smtp.protonmail.ch\nSMTP_PORT=587\nSMTP_USER=your@email.com\nSMTP_PASS=your-app-password\nEMAIL_TEST_MODE=false\n

Test Mode Behavior: - true: All emails sent to MailHog (visible at http://localhost:8025) - false: Emails sent to real recipients via SMTP

SiteSettings Override: Admins can override SMTP config via /app/settings (stored encrypted in DB).

"},{"location":"v2/deployment/environment-variables/#listmonk","title":"Listmonk","text":""},{"location":"v2/deployment/environment-variables/#database","title":"Database","text":"Variable Default Required Description LISTMONK_DB_PORT 5432 No Listmonk PostgreSQL port LISTMONK_DB_USER listmonk No Database username LISTMONK_DB_PASSWORD CHANGE_ME_LISTMONK_PASSWORD Yes Database password LISTMONK_DB_NAME listmonk No Database name"},{"location":"v2/deployment/environment-variables/#web-admin","title":"Web Admin","text":"Variable Default Required Description LISTMONK_PORT 9001 No Listmonk web UI port LISTMONK_WEB_ADMIN_USER admin No Web UI username LISTMONK_WEB_ADMIN_PASSWORD CHANGE_ME_LISTMONK_ADMIN Yes Web UI password"},{"location":"v2/deployment/environment-variables/#api-integration","title":"API Integration","text":"Variable Default Required Description LISTMONK_API_USER v2-api No API user (auto-created by listmonk-init) LISTMONK_API_TOKEN GENERATE_WITH_openssl_rand_hex_16 Yes API token (plaintext, not bcrypt) LISTMONK_ADMIN_USER v2-api No Alias for API user (V2 uses this) LISTMONK_ADMIN_PASSWORD SAME_AS_LISTMONK_API_TOKEN Yes Alias for API token LISTMONK_SYNC_ENABLED false No Enable participant/location sync LISTMONK_PROXY_PORT 9002 No OAuth proxy port (for future integrations)

API User Setup: The listmonk-init container auto-creates the API user by directly inserting into PostgreSQL.

Token Generation:

export LISTMONK_API_TOKEN=$(openssl rand -hex 16)\necho \"LISTMONK_API_TOKEN=${LISTMONK_API_TOKEN}\" >> .env\necho \"LISTMONK_ADMIN_PASSWORD=${LISTMONK_API_TOKEN}\" >> .env\n

Sync Behavior: - false: Manual sync only (default) - true: Auto-sync participants/locations to Listmonk lists on signup/create

"},{"location":"v2/deployment/environment-variables/#smtp-configuration","title":"SMTP Configuration","text":"Variable Default Required Description LISTMONK_SMTP_HOST mailhog-changemaker No SMTP server for newsletters LISTMONK_SMTP_PORT 1025 No SMTP port LISTMONK_SMTP_USER `` No SMTP username LISTMONK_SMTP_PASSWORD `` No SMTP password LISTMONK_SMTP_TLS_TYPE none No TLS mode (none | STARTTLS | TLS) LISTMONK_SMTP_FROM Changemaker Lite <noreply@cmlite.org> No Newsletter sender

listmonk-init Behavior: Configures dual SMTP providers (MailHog + production if credentials set).

"},{"location":"v2/deployment/environment-variables/#represent-api","title":"Represent API","text":"Variable Default Required Description REPRESENT_API_URL https://represent.opennorth.ca No Represent API endpoint (Canadian electoral data)

Free Public API: No authentication required.

Usage: Postal code \u2192 representative lookup for Influence campaigns.

"},{"location":"v2/deployment/environment-variables/#nocodb","title":"NocoDB","text":"Variable Default Required Description NOCODB_V2_PORT 8091 No NocoDB web UI port NOCODB_URL http://changemaker-v2-nocodb:8080 No Internal NocoDB URL NC_ADMIN_EMAIL admin@cmlite.org No Admin email NC_ADMIN_PASSWORD CHANGE_ME_NOCODB_PASSWORD Yes Admin password NC_PUBLIC_URL http://localhost:8091 No Public NocoDB URL

Database Connection: Uses separate nocodb_meta database (auto-created by init-nocodb-db.sh).

Connection String:

pg://changemaker-v2-postgres:5432?u=changemaker&p=PASSWORD&d=nocodb_meta\n

"},{"location":"v2/deployment/environment-variables/#media-management","title":"Media Management","text":"Variable Default Required Description ENABLE_MEDIA_FEATURES false No Enable media manager features MEDIA_API_PORT 4100 No Fastify media API port MEDIA_API_PUBLIC_URL http://media-api:4100 No Public media API URL MEDIA_ROOT /media/library No Media library root path MEDIA_UPLOADS /media/uploads No Upload staging directory MAX_UPLOAD_SIZE_GB 10 No Max video upload size (GB) PUBLIC_MEDIA_PORT 3100 No Public media gallery port VIDEO_PLAYER_DEBUG false No Enable video.js debug logging

Feature Flag: Set ENABLE_MEDIA_FEATURES=true to activate media routes.

Volume Mounts (in docker-compose.yml):

volumes:\n  - ${MEDIA_ROOT:-./media}:/media:ro              # Library (read-only)\n  - ${MEDIA_ROOT:-./media}/local/inbox:/media/local/inbox:rw  # Inbox (writable)\n

Supported Formats: MP4, MOV, AVI, MKV, WebM, M4V, FLV

"},{"location":"v2/deployment/environment-variables/#gitea","title":"Gitea","text":"Variable Default Required Description GITEA_URL http://gitea-changemaker:3000 No Internal Gitea URL GITEA_WEB_PORT 3030 No Gitea web UI port GITEA_SSH_PORT 2222 No Gitea SSH port (for git push/pull) GITEA_DB_TYPE mysql No Database type GITEA_DB_HOST gitea-db:3306 No MySQL hostname GITEA_DB_NAME gitea No Database name GITEA_DB_USER gitea No Database username GITEA_DB_PASSWD CHANGE_ME_GITEA_DB Yes Database password GITEA_DB_ROOT_PASSWORD CHANGE_ME_GITEA_ROOT Yes MySQL root password GITEA_ROOT_URL https://git.cmlite.org No Public Gitea URL GITEA_DOMAIN git.cmlite.org No Gitea domain

First-Time Setup: Visit http://localhost:3030 to create admin account.

Git Commands:

# Clone via HTTP\ngit clone http://localhost:3030/user/repo.git\n\n# Clone via SSH\ngit clone ssh://git@localhost:2222/user/repo.git\n

"},{"location":"v2/deployment/environment-variables/#n8n","title":"n8n","text":"Variable Default Required Description N8N_URL http://n8n-changemaker:5678 No Internal n8n URL N8N_PORT 5678 No n8n port N8N_HOST n8n.cmlite.org No Public n8n hostname N8N_ENCRYPTION_KEY CHANGE_ME_N8N_KEY Yes Workflow encryption key N8N_USER_EMAIL admin@example.com No Default admin email N8N_USER_PASSWORD CHANGE_ME_N8N_PASSWORD Yes Default admin password GENERIC_TIMEZONE UTC No Workflow timezone

First Start: n8n creates admin user with N8N_USER_EMAIL/N8N_USER_PASSWORD automatically.

Encryption Key: Used to encrypt credentials in workflows.

"},{"location":"v2/deployment/environment-variables/#mkdocs","title":"MkDocs","text":"Variable Default Required Description MKDOCS_PORT 4003 No MkDocs live preview port MKDOCS_SITE_SERVER_PORT 4001 No MkDocs static site port BASE_DOMAIN https://cmlite.org No Site URL for sitemap/canonical MKDOCS_PREVIEW_URL http://mkdocs:8000 No Internal preview URL MKDOCS_DOCS_PATH /mkdocs/docs No Documentation source path

Port Change: Was 4000 in V1, changed to 4003 to avoid conflict with API.

Live Reload: http://localhost:4003 (updates on file save)

Static Build: http://localhost:4001 (Nginx-served production build)

"},{"location":"v2/deployment/environment-variables/#code-server","title":"Code Server","text":"Variable Default Required Description CODE_SERVER_PORT 8888 No Code Server port CODE_SERVER_URL http://code-server:8080 No Internal Code Server URL USER_NAME coder No Code Server username

Access: http://localhost:8888

Password: Set in configs/code-server/.config/code-server/config.yaml

"},{"location":"v2/deployment/environment-variables/#homepage","title":"Homepage","text":"Variable Default Required Description HOMEPAGE_PORT 3010 No Homepage dashboard port HOMEPAGE_VAR_BASE_URL http://localhost No Base URL for service links

Configuration: Edit configs/homepage/services.yaml to customize dashboard.

"},{"location":"v2/deployment/environment-variables/#mini-qr","title":"Mini QR","text":"Variable Default Required Description MINI_QR_PORT 8089 No Mini QR service port MINI_QR_URL http://mini-qr:8080 No Internal Mini QR URL MINI_QR_EMBED_PORT 8885 No Nginx embed proxy port

Usage: Walk sheets + cut exports embed QR codes via API or iframe.

"},{"location":"v2/deployment/environment-variables/#mailhog","title":"MailHog","text":"Variable Default Required Description MAILHOG_SMTP_PORT 1025 No SMTP port (internal only) MAILHOG_WEB_PORT 8025 No Web UI port

Web UI: http://localhost:8025

SMTP: Only accessible from Docker network (not exposed to host).

"},{"location":"v2/deployment/environment-variables/#nar-import","title":"NAR Import","text":"Variable Default Required Description NAR_DATA_DIR /data No Path to NAR data directory (in container)

Host Mount (in docker-compose.yml):

volumes:\n  - ./data:/data:ro  # Read-only NAR data\n

Data Structure:

./data/\n\u2514\u2500 202501/  (YYYYMM)\n   \u251c\u2500 Addresses/\n   \u2502  \u251c\u2500 Address_10.txt  (PEI)\n   \u2502  \u251c\u2500 Address_24_part_1.txt  (Quebec part 1)\n   \u2502  \u2514\u2500 ...\n   \u2514\u2500 Locations/\n      \u251c\u2500 Location_10.txt\n      \u2514\u2500 ...\n

Download: https://www150.statcan.gc.ca/n1/pub/46-26-0002/462600022022001-eng.htm

"},{"location":"v2/deployment/environment-variables/#geocoding","title":"Geocoding","text":"Variable Default Required Description MAPBOX_API_KEY `` No Mapbox API key (optional, 100k free/month) GEOCODING_RATE_LIMIT_MS 1100 No Delay between provider requests (ms) GEOCODING_CACHE_ENABLED true No Enable Redis caching GEOCODING_CACHE_TTL_HOURS 24 No Cache TTL in hours GOOGLE_MAPS_API_KEY `` No Google Maps API key (optional, paid) GOOGLE_MAPS_ENABLED false No Enable Google geocoding provider GEOCODING_PARALLEL_ENABLED true No Parallel geocoding for bulk imports GEOCODING_BATCH_SIZE 10 No Batch size for parallel geocoding BULK_GEOCODE_ENABLED true No Enable bulk re-geocode feature BULK_GEOCODE_MAX_BATCH 5000 No Max locations per bulk geocode batch

Providers (in fallback order): 1. Nominatim (OpenStreetMap, free) 2. ArcGIS (free tier) 3. Photon (free) 4. Mapbox (100k free/month, requires API key) 5. LocationIQ (free tier) 6. Google (paid, most accurate)

Recommendation: Add MAPBOX_API_KEY for better accuracy without cost.

"},{"location":"v2/deployment/environment-variables/#pangolin-tunnel","title":"Pangolin Tunnel","text":"Variable Default Required Description PANGOLIN_API_URL https://api.bnkserve.org/v1 No Pangolin API endpoint PANGOLIN_API_KEY `` No Pangolin API key PANGOLIN_ORG_ID `` No Organization ID (from setup wizard) PANGOLIN_SITE_ID `` No Site ID (from setup wizard) PANGOLIN_ENDPOINT https://pangolin.bnkserve.org No Tunnel endpoint URL PANGOLIN_NEWT_ID `` No Newt connector ID PANGOLIN_NEWT_SECRET `` No Newt connector secret

Setup Workflow: 1. Visit /app/pangolin in admin GUI 2. Enter PANGOLIN_API_KEY 3. Create org \u2192 site \u2192 endpoint \u2192 resource 4. Copy NEWT_ID/NEWT_SECRET to .env 5. Restart Newt container

Manual Setup:

# Set API key\nexport PANGOLIN_API_KEY=your-api-key\n\n# Create org (returns ORG_ID)\ncurl -H \"Authorization: Bearer $PANGOLIN_API_KEY\" \\\n  https://api.bnkserve.org/v1/orgs \\\n  -d '{\"name\":\"My Organization\"}'\n\n# Create site (returns SITE_ID)\ncurl -H \"Authorization: Bearer $PANGOLIN_API_KEY\" \\\n  https://api.bnkserve.org/v1/sites \\\n  -d '{\"org_id\":\"ORG_ID\",\"name\":\"Production Site\"}'\n\n# Continue setup...\n

See Tunneling for complete guide.

"},{"location":"v2/deployment/environment-variables/#monitoring","title":"Monitoring","text":""},{"location":"v2/deployment/environment-variables/#prometheus","title":"Prometheus","text":"Variable Default Required Description PROMETHEUS_PORT 9090 No Prometheus port

Scrape Targets (configured in configs/prometheus/prometheus.yml): - changemaker-v2-api:4000/api/metrics (10s interval) - redis-exporter:9121 (15s interval) - cadvisor:8080 (15s interval) - node-exporter:9100 (15s interval)

Retention: 30 days (configured in docker-compose.yml command).

"},{"location":"v2/deployment/environment-variables/#grafana","title":"Grafana","text":"Variable Default Required Description GRAFANA_PORT 3001 No Grafana port GRAFANA_ADMIN_PASSWORD admin No Admin password GRAFANA_ROOT_URL http://localhost:3001 No Public Grafana URL

Default Login: admin / admin (change on first login)

Dashboards: 3 pre-configured dashboards auto-provisioned from configs/grafana/

"},{"location":"v2/deployment/environment-variables/#exporters","title":"Exporters","text":"Variable Default Required Description CADVISOR_PORT 8080 No cAdvisor container metrics port NODE_EXPORTER_PORT 9100 No Node exporter system metrics port REDIS_EXPORTER_PORT 9121 No Redis exporter port"},{"location":"v2/deployment/environment-variables/#alertmanager","title":"Alertmanager","text":"Variable Default Required Description ALERTMANAGER_PORT 9093 No Alertmanager port

Configuration: Edit configs/alertmanager/alertmanager.yml for notification receivers.

"},{"location":"v2/deployment/environment-variables/#gotify","title":"Gotify","text":"Variable Default Required Description GOTIFY_PORT 8889 No Gotify push notification server port GOTIFY_ADMIN_USER admin No Gotify admin username GOTIFY_ADMIN_PASSWORD admin No Gotify admin password

Usage: Create apps in Gotify UI, add webhook URL to Alertmanager.

"},{"location":"v2/deployment/environment-variables/#security-checklist","title":"Security Checklist","text":"

Before production deployment:

Validation:

# Check for remaining placeholders\ngrep -r \"CHANGE_ME\" .env\n\n# Verify secrets are different\necho \"JWT_ACCESS_SECRET: $(grep JWT_ACCESS_SECRET .env)\"\necho \"JWT_REFRESH_SECRET: $(grep JWT_REFRESH_SECRET .env)\"\necho \"ENCRYPTION_KEY: $(grep ENCRYPTION_KEY .env)\"\n

"},{"location":"v2/deployment/environment-variables/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/environment-variables/#missing-env-file","title":"Missing .env File","text":"

Symptoms: Containers fail to start with \"missing environment variable\" errors

Solution:

# Create from template\ncp .env.example .env\n\n# Verify file exists\nls -la .env\n

"},{"location":"v2/deployment/environment-variables/#invalid-environment-variables","title":"Invalid Environment Variables","text":"

Symptoms: API fails to start with Zod validation errors

Diagnosis:

# View API startup logs\ndocker compose logs api | grep -A10 \"Environment validation\"\n

Common errors: - JWT_ACCESS_SECRET too short (must be 32+ chars) - ENCRYPTION_KEY same as JWT_ACCESS_SECRET (must differ) - Invalid URL format (API_URL must start with http:// or https://)

Solution:

# Regenerate secrets\nexport JWT_ACCESS_SECRET=$(openssl rand -hex 32)\nexport ENCRYPTION_KEY=$(openssl rand -hex 32)\n\n# Update .env\nsed -i \"s/^JWT_ACCESS_SECRET=.*/JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}/\" .env\nsed -i \"s/^ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${ENCRYPTION_KEY}/\" .env\n\n# Restart API\ndocker compose restart api\n

"},{"location":"v2/deployment/environment-variables/#postgresql-connection-failures","title":"PostgreSQL Connection Failures","text":"

Symptoms: API logs show ECONNREFUSED or authentication failed

Diagnosis:

# Check PostgreSQL is running\ndocker compose ps v2-postgres\n\n# Test connection\ndocker compose exec api npx prisma db pull\n\n# Verify DATABASE_URL\ndocker compose exec api printenv | grep DATABASE_URL\n

Solution:

# Verify password matches in .env\ngrep V2_POSTGRES_PASSWORD .env\n\n# Restart PostgreSQL\ndocker compose restart v2-postgres\n\n# Wait for healthcheck\ndocker compose ps v2-postgres  # Should show (healthy)\n

"},{"location":"v2/deployment/environment-variables/#redis-connection-failures","title":"Redis Connection Failures","text":"

Symptoms: API logs show ECONNREFUSED or WRONGPASS invalid password

Diagnosis:

# Check Redis is running\ndocker compose ps redis\n\n# Test connection\ndocker compose exec redis redis-cli -a \"${REDIS_PASSWORD}\" ping\n

Solution:

# Verify password in .env\ngrep REDIS_PASSWORD .env\n\n# Ensure REDIS_URL includes password\ngrep REDIS_URL .env  # Should be redis://:PASSWORD@redis-changemaker:6379\n\n# Restart Redis\ndocker compose restart redis\n

"},{"location":"v2/deployment/environment-variables/#environment-variables-not-updating","title":"Environment Variables Not Updating","text":"

Symptoms: Changed .env but service still uses old value

Cause: Docker Compose reads .env at startup, not runtime

Solution:

# Recreate container (picks up new env vars)\ndocker compose up -d --force-recreate api\n\n# Or: stop and start\ndocker compose down\ndocker compose up -d\n

"},{"location":"v2/deployment/environment-variables/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/deployment/healthchecks/","title":"Docker Health Check Configuration","text":""},{"location":"v2/deployment/healthchecks/#overview","title":"Overview","text":"

Docker health checks provide automatic service monitoring and restart capabilities. Changemaker Lite V2 includes health checks for 7 critical services.

Benefits: - Automatic restart of unhealthy containers - Dependency management (depends_on with service_healthy) - Monitoring integration (Prometheus can scrape health status)

"},{"location":"v2/deployment/healthchecks/#services-with-health-checks","title":"Services with Health Checks","text":"Service Healthcheck Command Interval Timeout Retries Start Period api wget http://localhost:4000/api/health 15s 5s 3 30s media-api wget http://127.0.0.1:4100/health 15s 5s 3 30s admin wget http://127.0.0.1:3000/ 30s 5s 3 20s v2-postgres pg_isready -U changemaker 10s 5s 5 - redis redis-cli -a $REDIS_PASSWORD ping 10s 5s 5 - gitea-app curl http://localhost:3000/ 30s 5s 3 30s n8n wget http://localhost:5678/healthz 30s 5s 3 30s"},{"location":"v2/deployment/healthchecks/#health-check-configuration","title":"Health Check Configuration","text":""},{"location":"v2/deployment/healthchecks/#api-express","title":"API (Express)","text":"

docker-compose.yml:

api:\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:4000/api/health\"]\n    interval: 15s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n

Explanation: - test: Runs wget (Alpine image standard) to check /api/health endpoint - interval: Check every 15 seconds - timeout: Fail if no response in 5 seconds - retries: Mark unhealthy after 3 consecutive failures - start_period: 30s grace period on startup (allows migrations to run)

Health endpoint (api/src/server.ts):

app.get('/api/health', (req, res) => {\n  res.json({ status: 'ok', timestamp: new Date().toISOString() });\n});\n

Health states: - starting: Within start_period (30s) - healthy: Check passed - unhealthy: 3 consecutive failures

"},{"location":"v2/deployment/healthchecks/#media-api-fastify","title":"Media API (Fastify)","text":"

docker-compose.yml:

media-api:\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://127.0.0.1:4100/health\"]\n    interval: 15s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n

Health endpoint (api/src/media-server.ts):

app.get('/health', async (req, reply) => {\n  return { status: 'ok' };\n});\n

Note: Uses 127.0.0.1 instead of localhost (Alpine's wget prefers IP).

"},{"location":"v2/deployment/healthchecks/#admin-vite-dev-server","title":"Admin (Vite Dev Server)","text":"

docker-compose.yml:

admin:\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://127.0.0.1:3000/\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n    start_period: 20s\n

Explanation: - 30s interval: Less critical than backend (frontend can tolerate brief downtime) - 20s start period: Vite dev server starts quickly - Root path: Checks Vite is serving HTML (no dedicated /health endpoint)

"},{"location":"v2/deployment/healthchecks/#v2-postgresql","title":"V2 PostgreSQL","text":"

docker-compose.yml:

v2-postgres:\n  healthcheck:\n    test: [\"CMD-SHELL\", \"pg_isready -U changemaker\"]\n    interval: 10s\n    timeout: 5s\n    retries: 5\n

Explanation: - pg_isready: Built-in PostgreSQL health check utility - 10s interval: Fast detection of database issues - 5 retries: More tolerant (database startup can be slow) - No start_period: PostgreSQL has its own startup delay

pg_isready output:

# Healthy\n/var/run/postgresql:5432 - accepting connections\n\n# Unhealthy\n/var/run/postgresql:5432 - rejecting connections\n

"},{"location":"v2/deployment/healthchecks/#redis","title":"Redis","text":"

docker-compose.yml:

redis:\n  healthcheck:\n    test: [\"CMD\", \"redis-cli\", \"-a\", \"${REDIS_PASSWORD}\", \"ping\"]\n    interval: 10s\n    timeout: 5s\n    retries: 5\n

Explanation: - redis-cli ping: Returns PONG if healthy - -a ${REDIS_PASSWORD}: Authenticates with password (required since Security Audit) - 10s interval: Fast detection for critical cache service

PING output:

# Healthy\nPONG\n\n# Unhealthy\n(error) NOAUTH Authentication required\n

"},{"location":"v2/deployment/healthchecks/#gitea","title":"Gitea","text":"

docker-compose.yml:

gitea-app:\n  healthcheck:\n    test: [\"CMD\", \"curl\", \"-f\", \"http://localhost:3000/\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n

Explanation: - curl: Debian-based image (no wget) - -f: Fail on HTTP errors (non-200 response) - 30s interval: Supporting service (less critical)

Important: Gitea uses curl (not wget) because it's a Debian image, not Alpine.

"},{"location":"v2/deployment/healthchecks/#n8n","title":"n8n","text":"

docker-compose.yml:

n8n:\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:5678/healthz\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n

Explanation: - /healthz: n8n's built-in health endpoint - 30s interval: Workflow automation (not user-facing)

"},{"location":"v2/deployment/healthchecks/#dependency-chains","title":"Dependency Chains","text":""},{"location":"v2/deployment/healthchecks/#api-depends-on-database-redis","title":"API Depends on Database + Redis","text":"

docker-compose.yml:

api:\n  depends_on:\n    v2-postgres:\n      condition: service_healthy\n    redis:\n      condition: service_healthy\n

Effect: API container waits for PostgreSQL + Redis to be healthy before starting.

Startup sequence: 1. PostgreSQL starts \u2192 health checks begin 2. After 5 successful checks \u2192 marked healthy 3. Redis starts \u2192 health checks begin 4. After 5 successful checks \u2192 marked healthy 5. API starts (both dependencies healthy)

"},{"location":"v2/deployment/healthchecks/#media-api-depends-on-database","title":"Media API Depends on Database","text":"

docker-compose.yml:

media-api:\n  depends_on:\n    v2-postgres:\n      condition: service_healthy\n

Effect: Media API waits for PostgreSQL to be healthy.

"},{"location":"v2/deployment/healthchecks/#nocodb-depends-on-database","title":"NocoDB Depends on Database","text":"

docker-compose.yml:

nocodb-v2:\n  depends_on:\n    v2-postgres:\n      condition: service_healthy\n

Effect: NocoDB waits for its metadata database to be ready.

"},{"location":"v2/deployment/healthchecks/#monitoring-healthcheck-status","title":"Monitoring Healthcheck Status","text":""},{"location":"v2/deployment/healthchecks/#view-health-status","title":"View Health Status","text":"
# All services (shows health in STATUS column)\ndocker compose ps\n\n# Example output:\n# NAME                    STATUS\n# changemaker-v2-api      Up 2 hours (healthy)\n# changemaker-v2-postgres Up 2 hours (healthy)\n# redis-changemaker       Up 2 hours (healthy)\n

Health states: - (healthy): All checks passing - (unhealthy): Multiple checks failed - (health: starting): Within start_period

"},{"location":"v2/deployment/healthchecks/#filter-unhealthy-services","title":"Filter Unhealthy Services","text":"
# Show only unhealthy\ndocker compose ps | grep unhealthy\n\n# Count unhealthy\ndocker compose ps -q --status unhealthy | wc -l\n
"},{"location":"v2/deployment/healthchecks/#inspect-health-check-details","title":"Inspect Health Check Details","text":"
# Full health info for API\ndocker inspect changemaker-v2-api | jq '.[0].State.Health'\n\n# Example output:\n{\n  \"Status\": \"healthy\",\n  \"FailingStreak\": 0,\n  \"Log\": [\n    {\n      \"Start\": \"2026-02-13T14:30:00Z\",\n      \"End\": \"2026-02-13T14:30:01Z\",\n      \"ExitCode\": 0,\n      \"Output\": \"\"\n    }\n  ]\n}\n

Key fields: - Status: healthy, unhealthy, or starting - FailingStreak: Consecutive failed checks - Log: Last 5 health check results

"},{"location":"v2/deployment/healthchecks/#health-check-logs","title":"Health Check Logs","text":"
# View health check output\ndocker inspect changemaker-v2-api | jq '.[0].State.Health.Log[-1]'\n\n# Example (success):\n{\n  \"Start\": \"2026-02-13T14:30:00Z\",\n  \"End\": \"2026-02-13T14:30:01Z\",\n  \"ExitCode\": 0,\n  \"Output\": \"\"\n}\n\n# Example (failure):\n{\n  \"Start\": \"2026-02-13T14:35:00Z\",\n  \"End\": \"2026-02-13T14:35:05Z\",\n  \"ExitCode\": 1,\n  \"Output\": \"wget: can't connect to remote host (127.0.0.1): Connection refused\"\n}\n
"},{"location":"v2/deployment/healthchecks/#custom-health-checks","title":"Custom Health Checks","text":""},{"location":"v2/deployment/healthchecks/#advanced-api-health-check","title":"Advanced API Health Check","text":"

Check database + Redis connectivity:

api/src/server.ts:

app.get('/api/health', async (req, res) => {\n  const checks = {\n    database: false,\n    redis: false,\n  };\n\n  try {\n    await prisma.$queryRaw`SELECT 1`;\n    checks.database = true;\n  } catch (err) {\n    console.error('DB health check failed:', err);\n  }\n\n  try {\n    await redis.ping();\n    checks.redis = true;\n  } catch (err) {\n    console.error('Redis health check failed:', err);\n  }\n\n  const healthy = checks.database && checks.redis;\n  res.status(healthy ? 200 : 503).json({\n    status: healthy ? 'ok' : 'degraded',\n    checks,\n    timestamp: new Date().toISOString(),\n  });\n});\n

docker-compose.yml (no change needed \u2014 still checks /api/health):

healthcheck:\n  test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:4000/api/health\"]\n

"},{"location":"v2/deployment/healthchecks/#readiness-vs-liveness","title":"Readiness vs Liveness","text":"

Readiness: Service is ready to accept traffic (used by Kubernetes) Liveness: Service is running (Docker health checks)

Example (separate endpoints):

// Liveness (minimal check)\napp.get('/api/health', (req, res) => {\n  res.json({ status: 'ok' });\n});\n\n// Readiness (comprehensive check)\napp.get('/api/ready', async (req, res) => {\n  const dbReady = await checkDatabase();\n  const redisReady = await checkRedis();\n  const ready = dbReady && redisReady;\n  res.status(ready ? 200 : 503).json({ ready, dbReady, redisReady });\n});\n

Docker uses liveness (/api/health). Load balancer uses readiness (/api/ready).

"},{"location":"v2/deployment/healthchecks/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/healthchecks/#service-marked-unhealthy","title":"Service Marked Unhealthy","text":"

Diagnosis:

# Check logs\ndocker compose logs --tail=50 api\n\n# Check health check output\ndocker inspect changemaker-v2-api | jq '.[0].State.Health.Log[-1].Output'\n\n# Manually run health check\ndocker compose exec api wget -O- http://localhost:4000/api/health\n

Common causes: - Service crashed (check logs) - Health endpoint broken (test manually) - Timeout too short (increase in docker-compose.yml) - Database migration running (increase start_period)

"},{"location":"v2/deployment/healthchecks/#container-restarting-loop","title":"Container Restarting Loop","text":"

Symptoms: Container repeatedly marked unhealthy \u2192 restart \u2192 unhealthy

Diagnosis:

# Check restart count\ndocker inspect changemaker-v2-api | jq '.[0].RestartCount'\n\n# Check logs for errors\ndocker compose logs api | grep -i error\n

Common causes: - Health check too aggressive (increase retries/interval) - Service genuinely broken (fix code issue) - Resource limits too low (increase memory/CPU)

Solution:

# Temporarily disable health check\nhealthcheck:\n  disable: true\n\n# Or increase tolerance\nhealthcheck:\n  retries: 10\n  start_period: 60s\n

"},{"location":"v2/deployment/healthchecks/#health-check-command-not-found","title":"Health Check Command Not Found","text":"

Symptoms: Health check fails with \"wget: not found\" or \"curl: not found\"

Cause: Using wrong command for image type (Alpine vs Debian)

Solution:

Alpine images (api, media-api, redis, v2-postgres):

test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://...\"]\n

Debian images (gitea-app):

test: [\"CMD\", \"curl\", \"-f\", \"http://...\"]\n

"},{"location":"v2/deployment/healthchecks/#start-period-too-short","title":"Start Period Too Short","text":"

Symptoms: Service marked unhealthy immediately on startup

Cause: Database migrations or slow startup exceed start_period

Solution:

# Increase start_period\nhealthcheck:\n  start_period: 60s  # Was 30s\n

Monitor startup time:

# Measure time to first healthy\ndocker compose up -d api && \\\n  while ! docker compose ps api | grep -q healthy; do sleep 1; done && \\\n  echo \"Startup took $SECONDS seconds\"\n

"},{"location":"v2/deployment/healthchecks/#production-recommendations","title":"Production Recommendations","text":""},{"location":"v2/deployment/healthchecks/#timeout-configuration","title":"Timeout Configuration","text":"

Critical services (database, redis, api): - interval: 10-15s - timeout: 5s - retries: 3-5 - start_period: 30-60s

Supporting services (n8n, gitea, mailhog): - interval: 30-60s - timeout: 10s - retries: 3 - start_period: 30s

"},{"location":"v2/deployment/healthchecks/#restart-policies","title":"Restart Policies","text":"

Combine with restart policies:

api:\n  restart: unless-stopped  # Auto-restart on failure\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:4000/api/health\"]\n

Effect: Unhealthy container \u2192 restart \u2192 health checks resume.

"},{"location":"v2/deployment/healthchecks/#monitoring-integration","title":"Monitoring Integration","text":"

Prometheus exporter (future):

# Expose health check status as metrics\ndocker_healthcheck_status{container=\"changemaker-v2-api\"} 1\n

Alert on unhealthy:

- alert: ContainerUnhealthy\n  expr: docker_healthcheck_status == 0\n  for: 5m\n  labels:\n    severity: warning\n  annotations:\n    summary: \"Container {{ $labels.container }} unhealthy\"\n

"},{"location":"v2/deployment/healthchecks/#testing-health-checks","title":"Testing Health Checks","text":""},{"location":"v2/deployment/healthchecks/#manual-test","title":"Manual Test","text":"
# Start service\ndocker compose up -d api\n\n# Watch health status\nwatch -n2 'docker compose ps api'\n\n# Should see:\n# (health: starting) \u2192 (healthy)\n
"},{"location":"v2/deployment/healthchecks/#simulate-failure","title":"Simulate Failure","text":"
# Stop backend service\ndocker compose stop v2-postgres\n\n# Wait 15s (API health check interval)\nsleep 15\n\n# Check API status\ndocker compose ps api\n# Should show (unhealthy) after 3 failures (45s)\n\n# Restart backend\ndocker compose start v2-postgres\n\n# API should recover\ndocker compose ps api\n# Should show (healthy) after successful check\n
"},{"location":"v2/deployment/healthchecks/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/deployment/monitoring-stack/","title":"Monitoring Stack (Prometheus + Grafana)","text":""},{"location":"v2/deployment/monitoring-stack/#overview","title":"Overview","text":"

Changemaker Lite V2 includes a complete observability stack for production monitoring:

All monitoring services behind Docker Compose profile flag (opt-in).

"},{"location":"v2/deployment/monitoring-stack/#architecture","title":"Architecture","text":"
graph LR\n    subgraph \"Application Metrics\"\n        API[API<br/>:4000/api/metrics]\n        MEDIA[Media API<br/>:4100/metrics]\n    end\n\n    subgraph \"Infrastructure Metrics\"\n        CADVISOR[cAdvisor<br/>Container Stats]\n        NODE[Node Exporter<br/>Host Stats]\n        REDIS_EXP[Redis Exporter<br/>Redis Stats]\n    end\n\n    subgraph \"Monitoring Stack\"\n        PROM[Prometheus<br/>:9090]\n        GRAFANA[Grafana<br/>:3001]\n        ALERT[Alertmanager<br/>:9093]\n        GOTIFY[Gotify<br/>:8889]\n    end\n\n    API --> PROM\n    MEDIA --> PROM\n    CADVISOR --> PROM\n    NODE --> PROM\n    REDIS_EXP --> PROM\n\n    PROM --> GRAFANA\n    PROM --> ALERT\n    ALERT --> GOTIFY
"},{"location":"v2/deployment/monitoring-stack/#quick-start","title":"Quick Start","text":""},{"location":"v2/deployment/monitoring-stack/#enable-monitoring","title":"Enable Monitoring","text":"
# Start with monitoring profile\ndocker compose --profile monitoring up -d\n\n# Check services\ndocker compose ps | grep monitoring\n\n# Access dashboards\nopen http://localhost:3001  # Grafana (admin/admin)\nopen http://localhost:9090  # Prometheus\nopen http://localhost:9093  # Alertmanager\n
"},{"location":"v2/deployment/monitoring-stack/#prometheus-configuration","title":"Prometheus Configuration","text":""},{"location":"v2/deployment/monitoring-stack/#scrape-targets","title":"Scrape Targets","text":"

File: configs/prometheus/prometheus.yml

scrape_configs:\n  # V2 Unified API Metrics (10s interval)\n  - job_name: 'changemaker-v2-api'\n    static_configs:\n      - targets: ['changemaker-v2-api:4000']\n    metrics_path: '/api/metrics'\n    scrape_interval: 10s\n    scrape_timeout: 5s\n\n  # Redis Metrics (15s interval)\n  - job_name: 'redis'\n    static_configs:\n      - targets: ['redis-exporter:9121']\n    scrape_interval: 15s\n\n  # cAdvisor - Docker container metrics\n  - job_name: 'cadvisor'\n    static_configs:\n      - targets: ['cadvisor:8080']\n    scrape_interval: 15s\n\n  # Node Exporter - System metrics\n  - job_name: 'node'\n    static_configs:\n      - targets: ['node-exporter:9100']\n    scrape_interval: 15s\n\n  # Prometheus self-monitoring\n  - job_name: 'prometheus'\n    static_configs:\n      - targets: ['localhost:9090']\n\n  # Alertmanager monitoring\n  - job_name: 'alertmanager'\n    static_configs:\n      - targets: ['alertmanager:9093']\n    scrape_interval: 30s\n

Intervals: - 10s: API (real-time application metrics) - 15s: Infrastructure (host + containers + Redis) - 30s: Monitoring stack itself

"},{"location":"v2/deployment/monitoring-stack/#custom-metrics-cm_","title":"Custom Metrics (cm_*)","text":"

File: api/src/utils/metrics.ts

12 custom metrics for domain-specific monitoring:

Metric Type Labels Description cm_emails_sent_total Counter campaign_id Campaign emails sent successfully cm_emails_failed_total Counter campaign_id, error_type Failed email sends cm_email_queue_size Gauge - Current email queue size cm_email_send_duration_seconds Histogram - Email send latency cm_login_attempts_total Counter status Login attempts (success/failure) cm_active_sessions Gauge - Active refresh tokens cm_campaign_emails_total Counter campaign_id Total campaign emails created cm_response_submissions_total Counter - Response wall submissions cm_canvass_visits_total Counter outcome Canvass visits by outcome cm_active_canvass_sessions Gauge - Active canvass sessions cm_shift_signups_total Counter - Shift signups cm_external_service_up Gauge service External service health (1=up, 0=down)

HTTP metrics (standard prom-client): - http_requests_total - http_request_duration_seconds

Geocoding metrics: - cm_geocode_cache_hits_total - cm_geocode_cache_misses_total - cm_geocode_requests_total - cm_geocode_duration_seconds

Email template metrics: - cm_email_templates_updated_total - cm_email_test_sent_total - cm_email_template_rollback_total - cm_email_template_cache_hit/miss_total

Location query metrics: - cm_map_location_query_duration_seconds - cm_map_location_query_count_total - cm_map_location_result_count

"},{"location":"v2/deployment/monitoring-stack/#alert-rules","title":"Alert Rules","text":"

File: configs/prometheus/alerts.yml

12 alert rules across 4 groups:

"},{"location":"v2/deployment/monitoring-stack/#application-alerts","title":"Application Alerts","text":"
  1. ApplicationDown: API unreachable for 2 minutes
  2. HighErrorRate: >10% 5xx errors for 5 minutes
  3. EmailQueueBacklog: Queue size >100 for 10 minutes
  4. HighEmailFailureRate: >20% email failures for 10 minutes
  5. SuspiciousLoginActivity: >5 failed logins/sec for 2 minutes
  6. HighAPILatency: P95 latency >2s for 5 minutes
  7. ExternalServiceDown: External service unreachable for 5 minutes
"},{"location":"v2/deployment/monitoring-stack/#system-alerts","title":"System Alerts","text":"
  1. RedisDown: Redis unreachable for 1 minute
  2. DiskSpaceLow: <15% disk space for 5 minutes
  3. DiskSpaceCritical: <10% disk space for 2 minutes
  4. HighCPUUsage: >85% CPU for 10 minutes
  5. HighMemoryUsage: >85% memory for 10 minutes

Example Alert:

- alert: ApplicationDown\n  expr: up{job=\"changemaker-v2-api\"} == 0\n  for: 2m\n  labels:\n    severity: critical\n  annotations:\n    summary: \"V2 API is down\"\n    description: \"The Changemaker V2 API has been down for more than 2 minutes.\"\n

"},{"location":"v2/deployment/monitoring-stack/#data-retention","title":"Data Retention","text":"

docker-compose.yml:

prometheus:\n  command:\n    - '--storage.tsdb.retention.time=30d'  # 30 days\n

Disk usage: ~1-5GB for 30 days (depends on scrape frequency + cardinality).

Increase retention:

# Edit docker-compose.yml\n# Change to '--storage.tsdb.retention.time=90d'\n\n# Recreate container\ndocker compose --profile monitoring up -d --force-recreate prometheus\n

"},{"location":"v2/deployment/monitoring-stack/#grafana-configuration","title":"Grafana Configuration","text":""},{"location":"v2/deployment/monitoring-stack/#datasource","title":"Datasource","text":"

File: configs/grafana/datasources.yml

apiVersion: 1\n\ndatasources:\n  - name: Prometheus\n    type: prometheus\n    access: proxy\n    url: http://prometheus:9090\n    isDefault: true\n    editable: false\n

Auto-provisioned on Grafana startup.

"},{"location":"v2/deployment/monitoring-stack/#dashboards","title":"Dashboards","text":"

File: configs/grafana/dashboards.yml

apiVersion: 1\n\nproviders:\n  - name: 'Default'\n    folder: 'Changemaker Lite'\n    type: file\n    options:\n      path: /etc/grafana/provisioning/dashboards\n

3 pre-configured dashboards:

"},{"location":"v2/deployment/monitoring-stack/#1-application-overview","title":"1. Application Overview","text":"

File: configs/grafana/application-overview.json

Panels: - API uptime (last 24h) - Request rate (req/sec) - Error rate (%) - Email queue size - Active sessions - Campaign emails sent

Refresh: 10s

"},{"location":"v2/deployment/monitoring-stack/#2-api-performance","title":"2. API Performance","text":"

File: configs/grafana/api-performance.json

Panels: - Request latency (P50, P95, P99) - Requests by status code - Top 10 slowest endpoints - HTTP errors by route - Geocoding cache hit rate - Email send duration

Refresh: 30s

"},{"location":"v2/deployment/monitoring-stack/#3-system-health","title":"3. System Health","text":"

File: configs/grafana/system-health.json

Panels: - CPU usage (%) - Memory usage (%) - Disk space (GB free) - Network I/O (MB/s) - Container CPU throttling - Redis memory usage

Refresh: 1m

"},{"location":"v2/deployment/monitoring-stack/#first-login","title":"First Login","text":"
# Access Grafana\nopen http://localhost:3001\n\n# Default credentials\nUsername: admin\nPassword: admin\n\n# Change password on first login\n

Navigate: Dashboards \u2192 Changemaker Lite folder \u2192 Select dashboard

"},{"location":"v2/deployment/monitoring-stack/#alertmanager-configuration","title":"Alertmanager Configuration","text":""},{"location":"v2/deployment/monitoring-stack/#notification-receivers","title":"Notification Receivers","text":"

File: configs/alertmanager/alertmanager.yml

global:\n  resolve_timeout: 5m\n\nroute:\n  receiver: 'default'\n  group_by: ['alertname', 'severity']\n  group_wait: 30s\n  group_interval: 5m\n  repeat_interval: 4h\n\nreceivers:\n  - name: 'default'\n    # Email (example)\n    email_configs:\n      - to: 'admin@cmlite.org'\n        from: 'alerts@cmlite.org'\n        smarthost: 'smtp.example.com:587'\n        auth_username: 'alerts@cmlite.org'\n        auth_password: 'your-password'\n\n    # Slack (example)\n    slack_configs:\n      - api_url: 'https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK'\n        channel: '#alerts'\n        title: '{{ .GroupLabels.alertname }}'\n        text: '{{ range .Alerts }}{{ .Annotations.summary }}\\n{{ end }}'\n\n    # Gotify (push notifications)\n    webhook_configs:\n      - url: 'http://gotify:80/message?token=YOUR_GOTIFY_TOKEN'\n

Grouping: Combines similar alerts (prevents spam).

Repeat: Re-sends unresolved alerts every 4 hours.

"},{"location":"v2/deployment/monitoring-stack/#testing-alerts","title":"Testing Alerts","text":"

Manual test:

# Trigger test alert\ncurl -X POST http://localhost:9093/api/v1/alerts \\\n  -d '[{\n    \"labels\": {\"alertname\":\"TestAlert\",\"severity\":\"warning\"},\n    \"annotations\": {\"summary\":\"Test alert from curl\"}\n  }]'\n\n# Check Alertmanager UI\nopen http://localhost:9093\n

Force alert (stop API):

# Stop API (triggers ApplicationDown alert after 2m)\ndocker compose stop api\n\n# Check Prometheus alerts\nopen http://localhost:9090/alerts\n\n# Wait 2 minutes \u2192 Alert fires \u2192 Notification sent\n

"},{"location":"v2/deployment/monitoring-stack/#exporters","title":"Exporters","text":""},{"location":"v2/deployment/monitoring-stack/#cadvisor-container-metrics","title":"cAdvisor (Container Metrics)","text":"

Metrics: - CPU usage per container - Memory usage per container - Network I/O - Disk I/O

Access: http://localhost:8080

Configuration (docker-compose.yml):

cadvisor:\n  image: gcr.io/cadvisor/cadvisor:latest\n  container_name: cadvisor-changemaker\n  privileged: true  # Required for full access\n  volumes:\n    - /:/rootfs:ro\n    - /var/run:/var/run:ro\n    - /sys:/sys:ro\n    - /var/lib/docker/:/var/lib/docker:ro\n    - /dev/disk/:/dev/disk:ro\n  devices:\n    - /dev/kmsg\n

"},{"location":"v2/deployment/monitoring-stack/#node-exporter-host-metrics","title":"Node Exporter (Host Metrics)","text":"

Metrics: - CPU usage (all cores) - Memory usage (total, free, cached) - Disk usage (filesystem, mountpoints) - Network I/O (bytes, packets)

Access: http://localhost:9100/metrics

Configuration:

node-exporter:\n  command:\n    - '--path.rootfs=/host'\n    - '--path.procfs=/host/proc'\n    - '--path.sysfs=/host/sys'\n    - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'\n  volumes:\n    - /proc:/host/proc:ro\n    - /sys:/host/sys:ro\n    - /:/rootfs:ro\n

"},{"location":"v2/deployment/monitoring-stack/#redis-exporter","title":"Redis Exporter","text":"

Metrics: - Memory usage - Commands per second - Connected clients - Keyspace hits/misses - Evicted keys

Access: http://localhost:9121/metrics

Configuration:

redis-exporter:\n  environment:\n    - REDIS_ADDR=redis:6379\n    - REDIS_PASSWORD=${REDIS_PASSWORD}  # Authenticates with Redis\n

"},{"location":"v2/deployment/monitoring-stack/#gotify-push-notifications","title":"Gotify (Push Notifications)","text":"

Setup:

# Access Gotify UI\nopen http://localhost:8889\n\n# Login (default: admin/admin)\n\n# Create app \u2192 Copy token\n\n# Add to Alertmanager config:\nwebhook_configs:\n  - url: 'http://gotify:80/message?token=YOUR_TOKEN'\n

Mobile apps: Available for iOS/Android (receive push notifications).

"},{"location":"v2/deployment/monitoring-stack/#accessing-services","title":"Accessing Services","text":"Service URL Default Credentials Prometheus http://localhost:9090 None Grafana http://localhost:3001 admin / admin Alertmanager http://localhost:9093 None cAdvisor http://localhost:8080 None Node Exporter http://localhost:9100/metrics None Redis Exporter http://localhost:9121/metrics None Gotify http://localhost:8889 admin / admin"},{"location":"v2/deployment/monitoring-stack/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/monitoring-stack/#prometheus-not-scraping","title":"Prometheus Not Scraping","text":"

Symptoms: Missing data in Grafana dashboards

Diagnosis:

# Check Prometheus targets\nopen http://localhost:9090/targets\n\n# Look for errors (red) vs success (green)\n\n# Check API metrics endpoint\ncurl http://localhost:4000/api/metrics\n

Common causes: - API container not running - Wrong port in prometheus.yml - Network connectivity issue

Solution:

# Restart API\ndocker compose restart api\n\n# Reload Prometheus config\ndocker compose exec prometheus kill -HUP 1\n\n# Or restart Prometheus\ndocker compose restart prometheus\n

"},{"location":"v2/deployment/monitoring-stack/#grafana-dashboards-not-loading","title":"Grafana Dashboards Not Loading","text":"

Symptoms: Blank dashboards or \"No data\" errors

Diagnosis:

# Check Grafana logs\ndocker compose logs grafana | tail -50\n\n# Check datasource\nopen http://localhost:3001/datasources\n\n# Test Prometheus query\ncurl http://prometheus:9090/api/v1/query?query=up\n

Solution:

# Verify datasource URL\n# Should be http://prometheus:9090 (container name, not localhost)\n\n# Restart Grafana\ndocker compose restart grafana\n

"},{"location":"v2/deployment/monitoring-stack/#alerts-not-firing","title":"Alerts Not Firing","text":"

Symptoms: No notifications despite issues

Diagnosis:

# Check Prometheus alerts\nopen http://localhost:9090/alerts\n\n# Check Alertmanager\nopen http://localhost:9093\n\n# Verify alert rules loaded\ncurl http://localhost:9090/api/v1/rules\n

Solution:

# Reload Prometheus config\ndocker compose exec prometheus kill -HUP 1\n\n# Check alerts.yml syntax\ndocker compose exec prometheus promtool check rules /etc/prometheus/alerts.yml\n\n# Test notification receiver\ncurl -X POST http://localhost:9093/api/v1/alerts -d '[...]'\n

"},{"location":"v2/deployment/monitoring-stack/#production-best-practices","title":"Production Best Practices","text":""},{"location":"v2/deployment/monitoring-stack/#secure-grafana","title":"Secure Grafana","text":"

Change admin password:

# Via UI: Admin \u2192 Profile \u2192 Change Password\n\n# Via env var (docker-compose.yml):\nenvironment:\n  - GF_SECURITY_ADMIN_PASSWORD=<strong-password>\n

Disable signup:

environment:\n  - GF_USERS_ALLOW_SIGN_UP=false  # Already set\n

"},{"location":"v2/deployment/monitoring-stack/#alert-tuning","title":"Alert Tuning","text":"

Avoid false positives: Increase for duration in critical alerts.

Example (before):

- alert: DiskSpaceLow\n  expr: disk_free_percent < 15\n  for: 1m  # Too aggressive\n

Example (after):

- alert: DiskSpaceLow\n  expr: disk_free_percent < 15\n  for: 10m  # More reasonable\n

"},{"location":"v2/deployment/monitoring-stack/#external-storage-long-term","title":"External Storage (Long-Term)","text":"

Prometheus supports remote write to: - Thanos: Long-term storage (S3/GCS) - Cortex: Multi-tenant Prometheus - VictoriaMetrics: High-performance storage

Example (Thanos):

# prometheus.yml\nremote_write:\n  - url: \"http://thanos-receive:19291/api/v1/receive\"\n

"},{"location":"v2/deployment/monitoring-stack/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/deployment/nginx/","title":"Nginx Reverse Proxy Configuration","text":""},{"location":"v2/deployment/nginx/#overview","title":"Overview","text":"

Nginx serves as the central reverse proxy for Changemaker Lite V2, routing traffic to 15+ backend services via subdomain-based routing. It handles SSL termination, security headers, static file serving, and WebSocket upgrades.

Key Responsibilities:

Architecture:

Internet \u2192 Nginx (:80, :443) \u2192 [Docker Internal Network]\n                                  \u251c\u2500 api:4000 (Express)\n                                  \u251c\u2500 media-api:4100 (Fastify)\n                                  \u251c\u2500 admin:3000 (Vite / static)\n                                  \u251c\u2500 nocodb:8080\n                                  \u251c\u2500 listmonk:9000\n                                  \u251c\u2500 gitea:3000\n                                  \u251c\u2500 n8n:5678\n                                  \u251c\u2500 mkdocs:8000\n                                  \u251c\u2500 code-server:8080\n                                  \u251c\u2500 mailhog:8025\n                                  \u251c\u2500 mini-qr:8080\n                                  \u251c\u2500 homepage:3000\n                                  \u251c\u2500 grafana:3000\n                                  \u2514\u2500 public-media:80\n
"},{"location":"v2/deployment/nginx/#architecture","title":"Architecture","text":"
graph LR\n    subgraph \"External Access\"\n        USER[User Browser]\n        TUNNEL[Pangolin Tunnel]\n    end\n\n    subgraph \"Nginx Proxy :80, :443\"\n        NGINX{Nginx<br/>Subdomain Router}\n    end\n\n    subgraph \"Backend Services (Docker Network)\"\n        API[api:4000<br/>Express]\n        MEDIA[media-api:4100<br/>Fastify]\n        ADMIN[admin:3000<br/>Vite]\n        NOCODB[nocodb:8080]\n        LISTMONK[listmonk:9000]\n        GITEA[gitea:3000]\n        N8N[n8n:5678]\n        MKDOCS[mkdocs:8000]\n        CODE[code-server:8080]\n    end\n\n    USER -->|HTTP/HTTPS| NGINX\n    TUNNEL -->|HTTP| NGINX\n\n    NGINX -->|api.cmlite.org| API\n    NGINX -->|api.cmlite.org/media| MEDIA\n    NGINX -->|app.cmlite.org| ADMIN\n    NGINX -->|db.cmlite.org| NOCODB\n    NGINX -->|listmonk.cmlite.org| LISTMONK\n    NGINX -->|git.cmlite.org| GITEA\n    NGINX -->|n8n.cmlite.org| N8N\n    NGINX -->|docs.cmlite.org| MKDOCS\n    NGINX -->|code.cmlite.org| CODE
"},{"location":"v2/deployment/nginx/#configuration-files","title":"Configuration Files","text":"

Nginx configuration split across multiple files:

File Purpose Type nginx/nginx.conf Global settings, gzip, security headers Main config nginx/conf.d/default.conf Localhost fallback, path-based routing Server block nginx/conf.d/api.conf API subdomain routing (Express + Fastify) Server block nginx/conf.d/services.conf Supporting service subdomains Server blocks (12+)

Configuration hierarchy:

nginx.conf\n\u251c\u2500 Global: worker_processes, events, http\n\u251c\u2500 Security headers (applied to all)\n\u251c\u2500 Gzip compression\n\u251c\u2500 Docker DNS resolver (127.0.0.11)\n\u2514\u2500 Include conf.d/*.conf\n    \u251c\u2500 default.conf (localhost)\n    \u251c\u2500 api.conf (api.cmlite.org)\n    \u2514\u2500 services.conf (all other subdomains)\n

"},{"location":"v2/deployment/nginx/#global-configuration-nginxconf","title":"Global Configuration (nginx.conf)","text":"

File: nginx/nginx.conf

"},{"location":"v2/deployment/nginx/#worker-configuration","title":"Worker Configuration","text":"
worker_processes auto;\nerror_log /var/log/nginx/error.log warn;\npid /var/run/nginx.pid;\n\nevents {\n    worker_connections 1024;\n}\n

Explanation: - worker_processes auto: Detects CPU cores (1 worker per core) - worker_connections 1024: Max 1024 concurrent connections per worker - Total capacity: auto \u00d7 1024 (e.g., 4 cores = 4096 connections)

"},{"location":"v2/deployment/nginx/#http-block","title":"HTTP Block","text":"
http {\n    include /etc/nginx/mime.types;\n    default_type application/octet-stream;\n\n    log_format main '$remote_addr - $remote_user [$time_local] \"$request\" '\n                    '$status $body_bytes_sent \"$http_referer\" '\n                    '\"$http_user_agent\" \"$http_x_forwarded_for\"';\n\n    access_log /var/log/nginx/access.log main;\n\n    sendfile on;\n    tcp_nopush on;\n    tcp_nodelay on;\n    keepalive_timeout 65;\n    types_hash_max_size 2048;\n    client_max_body_size 50m;  # Default max upload size\n\n    # Include server blocks\n    include /etc/nginx/conf.d/*.conf;\n}\n

Key Settings: - sendfile on: Optimized file serving (kernel-level copy) - tcp_nopush on: Sends HTTP headers in single packet - client_max_body_size 50m: Default upload limit (overridden per location)

"},{"location":"v2/deployment/nginx/#gzip-compression","title":"Gzip Compression","text":"
# Gzip compression\ngzip on;\ngzip_vary on;\ngzip_proxied any;\ngzip_comp_level 6;\ngzip_types text/plain text/css application/json application/javascript\n           text/xml application/xml application/xml+rss text/javascript\n           image/svg+xml;\n

Performance Impact: - CPU usage: Level 6 provides 80% compression with moderate CPU cost - Bandwidth savings: ~60-80% reduction for text/JSON responses - Excluded: Images, video (already compressed)

"},{"location":"v2/deployment/nginx/#security-headers","title":"Security Headers","text":"
# Security headers (applied globally)\nadd_header X-Content-Type-Options \"nosniff\" always;\nadd_header X-XSS-Protection \"1; mode=block\" always;\nadd_header Referrer-Policy \"strict-origin-when-cross-origin\" always;\nadd_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\nadd_header Permissions-Policy \"geolocation=(self), microphone=(), camera=()\" always;\n

Header Explanation: - X-Content-Type-Options: Prevents MIME sniffing attacks - X-XSS-Protection: Enables browser XSS filter (legacy browsers) - Referrer-Policy: Controls referer header sent to external sites - HSTS: Forces HTTPS for 1 year (31536000 seconds) - Permissions-Policy: Restricts geolocation/media access

Note: X-Frame-Options set per server block (not global).

"},{"location":"v2/deployment/nginx/#docker-dns-resolver","title":"Docker DNS Resolver","text":"
# Docker internal DNS \u2014 enables runtime resolution\nresolver 127.0.0.11 valid=30s;\n

Purpose: Docker's embedded DNS server at 127.0.0.11 resolves container names.

Why needed: Allows Nginx to start even when optional services are down. Without this, Nginx fails to start if any upstream is missing.

Usage pattern:

location / {\n    set $upstream_api http://changemaker-v2-api:4000;\n    proxy_pass $upstream_api;  # Resolves at request time, not config parse\n}\n

Alternative (fails if container missing):

proxy_pass http://changemaker-v2-api:4000;  # Resolved at config parse \u2014 fails if down\n

"},{"location":"v2/deployment/nginx/#subdomain-routing","title":"Subdomain Routing","text":""},{"location":"v2/deployment/nginx/#default-server-localhost","title":"Default Server (localhost)","text":"

File: nginx/conf.d/default.conf

server {\n    listen 80 default_server;\n    server_name localhost _;\n    add_header X-Frame-Options \"SAMEORIGIN\" always;\n\n    # Admin GUI (default)\n    location / {\n        set $upstream_admin http://changemaker-v2-admin:3000;\n        proxy_pass $upstream_admin;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n    }\n\n    # Media API (must come BEFORE /api/ for longest prefix match)\n    location /api/media/ {\n        set $upstream_media http://changemaker-media-api:4100;\n        proxy_pass $upstream_media;\n        # ... (proxy headers)\n\n        # Large upload support\n        client_max_body_size 10G;\n        proxy_read_timeout 3600s;\n        proxy_connect_timeout 75s;\n        proxy_request_buffering off;\n    }\n\n    # API (Express)\n    location /api/ {\n        set $upstream_api http://changemaker-v2-api:4000;\n        proxy_pass $upstream_api;\n        # ... (proxy headers)\n    }\n\n    # Public Media Gallery\n    location /gallery/ {\n        proxy_pass http://changemaker-public-media:80/;\n        # ... (proxy headers)\n    }\n}\n

Routing Logic: 1. Request to http://localhost/api/media/videos \u2192 media-api:4100 2. Request to http://localhost/api/campaigns \u2192 api:4000 3. Request to http://localhost/ \u2192 admin:3000 4. Request to http://localhost/gallery/ \u2192 public-media:80

Important: /api/media/ location must come before /api/ in config file (longest prefix match).

"},{"location":"v2/deployment/nginx/#api-subdomain-apicmliteorg","title":"API Subdomain (api.cmlite.org)","text":"

File: nginx/conf.d/api.conf

server {\n    listen 80;\n    server_name api.cmlite.org;\n    add_header X-Frame-Options \"SAMEORIGIN\" always;\n\n    # Media API endpoints (must come BEFORE / for longest prefix match)\n    location /media/ {\n        set $upstream_media http://changemaker-media-api:4100/api/;\n        proxy_pass $upstream_media;\n        # ... (proxy headers)\n\n        # Large upload support\n        client_max_body_size 10G;\n        proxy_read_timeout 3600s;\n        proxy_connect_timeout 75s;\n        proxy_request_buffering off;\n\n        # WebSocket support\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n    }\n\n    # Main API (Express)\n    location / {\n        set $upstream_api http://changemaker-v2-api:4000;\n        proxy_pass $upstream_api;\n        # ... (proxy headers)\n        proxy_read_timeout 300s;\n        proxy_connect_timeout 75s;\n    }\n}\n

URL Mapping: - http://api.cmlite.org/media/videos \u2192 http://changemaker-media-api:4100/api/videos - http://api.cmlite.org/auth/login \u2192 http://changemaker-v2-api:4000/auth/login

Critical: Media API location includes /api/ in proxy_pass to rewrite path.

"},{"location":"v2/deployment/nginx/#service-subdomains","title":"Service Subdomains","text":"

File: nginx/conf.d/services.conf

"},{"location":"v2/deployment/nginx/#gitea-gitcmliteorg","title":"Gitea (git.cmlite.org)","text":"
server {\n    listen 80;\n    server_name git.cmlite.org;\n    add_header Content-Security-Policy \"frame-ancestors 'self' app.cmlite.org\" always;\n\n    # Increase max body size for large git pushes (2GB)\n    client_max_body_size 2048M;\n\n    location / {\n        set $upstream_gitea http://gitea-changemaker:3000;\n        proxy_pass $upstream_gitea;\n        proxy_hide_header X-Frame-Options;  # Allow iframe embedding\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n

Key Features: - CSP frame-ancestors: Allows embedding in app.cmlite.org (admin GUI) - proxy_hide_header X-Frame-Options: Strips Gitea's default DENY policy - 2GB upload limit: For large repository pushes

"},{"location":"v2/deployment/nginx/#n8n-n8ncmliteorg","title":"n8n (n8n.cmlite.org)","text":"
server {\n    listen 80;\n    server_name n8n.cmlite.org;\n    add_header Content-Security-Policy \"frame-ancestors 'self' app.cmlite.org\" always;\n\n    location / {\n        set $upstream_n8n http://n8n-changemaker:5678;\n        proxy_pass $upstream_n8n;\n        proxy_hide_header X-Frame-Options;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";  # WebSocket support\n    }\n}\n

WebSocket Headers: - Upgrade: $http_upgrade: Passes WebSocket upgrade header - Connection: \"upgrade\": Indicates protocol upgrade

Required for: n8n workflow editor, MailHog live updates, MkDocs live reload

"},{"location":"v2/deployment/nginx/#nocodb-dbcmliteorg","title":"NocoDB (db.cmlite.org)","text":"
server {\n    listen 80;\n    server_name db.cmlite.org;\n    add_header Content-Security-Policy \"frame-ancestors 'self' app.cmlite.org\" always;\n\n    location / {\n        set $upstream_nocodb http://changemaker-v2-nocodb:8080;\n        proxy_pass $upstream_nocodb;\n        proxy_hide_header X-Frame-Options;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n

Iframe Embedding: - frame-ancestors 'self' app.cmlite.org: Allows admin GUI to embed NocoDB - proxy_hide_header X-Frame-Options: Removes NocoDB's default SAMEORIGIN policy

"},{"location":"v2/deployment/nginx/#mkdocs-docscmliteorg","title":"MkDocs (docs.cmlite.org)","text":"
server {\n    listen 80;\n    server_name docs.cmlite.org;\n    add_header Content-Security-Policy \"frame-ancestors 'self' app.cmlite.org\" always;\n\n    location / {\n        set $upstream_mkdocs http://mkdocs-changemaker:8000;\n        proxy_pass $upstream_mkdocs;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";  # Live reload WebSocket\n    }\n}\n

Live Reload: MkDocs Material theme uses WebSocket for live reload during development.

"},{"location":"v2/deployment/nginx/#code-server-codecmliteorg","title":"Code Server (code.cmlite.org)","text":"
server {\n    listen 80;\n    server_name code.cmlite.org;\n    add_header Content-Security-Policy \"frame-ancestors 'self' app.cmlite.org\" always;\n\n    location / {\n        set $upstream_code http://code-server-changemaker:8080;\n        proxy_pass $upstream_code;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";  # VS Code WebSocket\n    }\n}\n

WebSocket Usage: Code Server uses WebSockets for terminal, file watching, language server.

"},{"location":"v2/deployment/nginx/#mailhog-mailcmliteorg","title":"MailHog (mail.cmlite.org)","text":"
server {\n    listen 80;\n    server_name mail.cmlite.org;\n    add_header Content-Security-Policy \"frame-ancestors 'self' app.cmlite.org\" always;\n\n    location / {\n        set $upstream_mailhog http://mailhog-changemaker:8025;\n        proxy_pass $upstream_mailhog;\n        proxy_hide_header X-Frame-Options;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        # WebSocket support for MailHog live updates\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n    }\n}\n

Live Updates: MailHog uses WebSocket to push new emails to browser without polling.

"},{"location":"v2/deployment/nginx/#listmonk-listmonkcmliteorg","title":"Listmonk (listmonk.cmlite.org)","text":"
server {\n    listen 80;\n    server_name listmonk.cmlite.org;\n    add_header X-Frame-Options \"SAMEORIGIN\" always;\n\n    location / {\n        set $upstream_listmonk http://listmonk-app:9000;\n        proxy_pass $upstream_listmonk;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n

No Iframe: Listmonk not embedded in admin (accessed directly), so SAMEORIGIN policy kept.

"},{"location":"v2/deployment/nginx/#grafana-grafanacmliteorg","title":"Grafana (grafana.cmlite.org)","text":"
server {\n    listen 80;\n    server_name grafana.cmlite.org;\n    add_header X-Frame-Options \"SAMEORIGIN\" always;\n\n    location / {\n        set $upstream_grafana http://grafana-changemaker:3000;\n        proxy_pass $upstream_grafana;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";  # Grafana live updates\n    }\n}\n

WebSocket: Grafana uses WebSocket for live dashboard updates.

"},{"location":"v2/deployment/nginx/#mini-qr-qrcmliteorg","title":"Mini QR (qr.cmlite.org)","text":"
server {\n    listen 80;\n    server_name qr.cmlite.org;\n    add_header Content-Security-Policy \"frame-ancestors 'self' app.cmlite.org\" always;\n\n    location / {\n        set $upstream_miniqr http://mini-qr:8080;\n        proxy_pass $upstream_miniqr;\n        proxy_hide_header X-Frame-Options;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n

Iframe Embedding: Admin GUI embeds Mini QR for walk sheet previews.

"},{"location":"v2/deployment/nginx/#root-domain-cmliteorg","title":"Root Domain (cmlite.org)","text":"
server {\n    listen 80;\n    server_name cmlite.org;\n\n    location / {\n        set $upstream_site http://mkdocs-site-server-changemaker:80;\n        proxy_pass $upstream_site;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n

Purpose: Serves MkDocs static site (production build) on root domain.

"},{"location":"v2/deployment/nginx/#homepage-homecmliteorg","title":"Homepage (home.cmlite.org)","text":"
server {\n    listen 80;\n    server_name home.cmlite.org;\n    add_header X-Frame-Options \"SAMEORIGIN\" always;\n\n    location / {\n        set $upstream_homepage http://homepage-changemaker:3000;\n        proxy_pass $upstream_homepage;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n

Dashboard: Service status dashboard with Docker integration.

"},{"location":"v2/deployment/nginx/#embed-proxy-ports","title":"Embed Proxy Ports","text":"

Purpose: Allow admin GUI to iframe services via localhost ports (bypassing subdomain requirements).

Ports: 8881-8885 (NocoDB, n8n, Gitea, MailHog, Mini QR)

Configuration (in services.conf):

# NocoDB embed proxy (port 8881)\nserver {\n    listen 8881;\n    location / {\n        set $upstream_nocodb http://changemaker-v2-nocodb:8080;\n        proxy_pass $upstream_nocodb;\n        proxy_hide_header X-Frame-Options;\n        proxy_hide_header Content-Security-Policy;  # Strip all frame restrictions\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n\n# n8n embed proxy (port 8882)\nserver {\n    listen 8882;\n    location / {\n        set $upstream_n8n http://n8n-changemaker:5678;\n        proxy_pass $upstream_n8n;\n        proxy_hide_header X-Frame-Options;\n        proxy_hide_header Content-Security-Policy;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n    }\n}\n\n# Gitea embed proxy (port 8883)\nserver {\n    listen 8883;\n    client_max_body_size 2048M;  # Large git pushes\n    location / {\n        set $upstream_gitea http://gitea-changemaker:3000;\n        proxy_pass $upstream_gitea;\n        proxy_hide_header X-Frame-Options;\n        proxy_hide_header Content-Security-Policy;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n\n# MailHog embed proxy (port 8884)\nserver {\n    listen 8884;\n    location / {\n        set $upstream_mailhog http://mailhog-changemaker:8025;\n        proxy_pass $upstream_mailhog;\n        proxy_hide_header X-Frame-Options;\n        proxy_hide_header Content-Security-Policy;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n    }\n}\n\n# Mini QR embed proxy (port 8885)\nserver {\n    listen 8885;\n    location / {\n        set $upstream_miniqr http://mini-qr:8080;\n        proxy_pass $upstream_miniqr;\n        proxy_hide_header X-Frame-Options;\n        proxy_hide_header Content-Security-Policy;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n

Usage in Admin GUI:

<iframe src=\"http://localhost:8881\" />  {/* NocoDB */}\n<iframe src=\"http://localhost:8882\" />  {/* n8n */}\n<iframe src=\"http://localhost:8883\" />  {/* Gitea */}\n<iframe src=\"http://localhost:8884\" />  {/* MailHog */}\n<iframe src=\"http://localhost:8885\" />  {/* Mini QR */}\n

Exposed in docker-compose.yml:

nginx:\n  ports:\n    - \"80:80\"\n    - \"443:443\"\n    - \"8881:8881\"  # NocoDB\n    - \"8882:8882\"  # n8n\n    - \"8883:8883\"  # Gitea\n    - \"8884:8884\"  # MailHog\n    - \"8885:8885\"  # Mini QR\n

"},{"location":"v2/deployment/nginx/#proxy-configuration","title":"Proxy Configuration","text":""},{"location":"v2/deployment/nginx/#standard-proxy-headers","title":"Standard Proxy Headers","text":"

All proxy locations should include:

proxy_set_header Host $host;\nproxy_set_header X-Real-IP $remote_addr;\nproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\nproxy_set_header X-Forwarded-Proto $scheme;\n

Header Explanation: - Host: Preserves original hostname (e.g., api.cmlite.org) - X-Real-IP: Client's IP address - X-Forwarded-For: Chain of proxy IPs (adds to existing list) - X-Forwarded-Proto: HTTP or HTTPS (used by backend for redirect logic)

"},{"location":"v2/deployment/nginx/#websocket-upgrade","title":"WebSocket Upgrade","text":"

Required for: n8n, MailHog, MkDocs, Code Server, Grafana

proxy_set_header Upgrade $http_upgrade;\nproxy_set_header Connection \"upgrade\";\n

Explanation: - Upgrade: websocket: Browser requests protocol upgrade - Connection: upgrade: Indicates connection will persist

Without these headers: WebSocket connections fail with 400 Bad Request.

"},{"location":"v2/deployment/nginx/#timeouts","title":"Timeouts","text":"

Default timeouts:

proxy_read_timeout 300s;     # 5 minutes\nproxy_connect_timeout 75s;   # 75 seconds\n

Media API timeouts (video uploads):

proxy_read_timeout 3600s;    # 1 hour\nproxy_connect_timeout 75s;\n

Why longer: FFprobe video analysis + large file uploads take time.

"},{"location":"v2/deployment/nginx/#upload-size-limits","title":"Upload Size Limits","text":"

Global default (nginx.conf):

client_max_body_size 50m;\n

Per-location overrides: - Media API: client_max_body_size 10G; (video uploads) - Gitea: client_max_body_size 2048M; (large git pushes)

"},{"location":"v2/deployment/nginx/#request-buffering","title":"Request Buffering","text":"

Media API (disable buffering for streaming uploads):

proxy_request_buffering off;\n

Effect: Nginx streams request body directly to backend (no temp file).

Benefits: - Lower disk I/O on Nginx server - Faster upload start time - Reduced memory usage

Trade-off: Backend must handle slow clients (Fastify multipart does this).

"},{"location":"v2/deployment/nginx/#ssltls-configuration","title":"SSL/TLS Configuration","text":""},{"location":"v2/deployment/nginx/#certificate-paths","title":"Certificate Paths","text":"

Recommended structure:

/etc/letsencrypt/live/cmlite.org/\n\u251c\u2500 fullchain.pem  (certificate + intermediate)\n\u251c\u2500 privkey.pem    (private key)\n\u2514\u2500 chain.pem      (intermediate CA)\n

Nginx SSL block:

server {\n    listen 443 ssl http2;\n    server_name api.cmlite.org;\n\n    ssl_certificate /etc/letsencrypt/live/cmlite.org/fullchain.pem;\n    ssl_certificate_key /etc/letsencrypt/live/cmlite.org/privkey.pem;\n\n    # Strong TLS configuration\n    ssl_protocols TLSv1.2 TLSv1.3;\n    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';\n    ssl_prefer_server_ciphers on;\n    ssl_session_cache shared:SSL:10m;\n    ssl_session_timeout 10m;\n\n    # ... location blocks\n}\n

"},{"location":"v2/deployment/nginx/#http-to-https-redirect","title":"HTTP to HTTPS Redirect","text":"
server {\n    listen 80;\n    server_name api.cmlite.org;\n\n    # Redirect all HTTP to HTTPS\n    return 301 https://$host$request_uri;\n}\n\nserver {\n    listen 443 ssl http2;\n    server_name api.cmlite.org;\n    # ... SSL config + locations\n}\n
"},{"location":"v2/deployment/nginx/#hsts-header","title":"HSTS Header","text":"

Already applied globally (in nginx.conf):

add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n

Effect: Browser caches HTTPS requirement for 1 year.

Important: Only enable after verifying HTTPS works (can't easily undo).

"},{"location":"v2/deployment/nginx/#wildcard-certificates","title":"Wildcard Certificates","text":"

For *.cmlite.org (Let's Encrypt DNS challenge):

certbot certonly --dns-cloudflare \\\n  --dns-cloudflare-credentials ~/.secrets/cloudflare.ini \\\n  -d cmlite.org -d \"*.cmlite.org\"\n

Single cert covers all subdomains: - api.cmlite.org - app.cmlite.org - db.cmlite.org - etc.

See SSL/TLS for complete certificate management.

"},{"location":"v2/deployment/nginx/#static-file-serving","title":"Static File Serving","text":""},{"location":"v2/deployment/nginx/#admin-gui-production-build","title":"Admin GUI Production Build","text":"

Dockerfile multi-stage build (admin/Dockerfile):

# Build stage\nFROM node:20-alpine AS builder\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci\nCOPY . .\nRUN npm run build\n\n# Production stage\nFROM nginx:alpine\nCOPY --from=builder /app/dist /usr/share/nginx/html\nCOPY nginx.conf /etc/nginx/nginx.conf\nEXPOSE 80\nCMD [\"nginx\", \"-g\", \"daemon off;\"]\n

Nginx serves static files (no Node.js in production):

server {\n    listen 80;\n    server_name app.cmlite.org;\n\n    root /usr/share/nginx/html;\n    index index.html;\n\n    # React Router support (all routes \u2192 index.html)\n    location / {\n        try_files $uri $uri/ /index.html;\n    }\n\n    # API proxy\n    location /api/ {\n        proxy_pass http://changemaker-v2-api:4000;\n        # ... proxy headers\n    }\n\n    # Cache static assets\n    location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {\n        expires 1y;\n        add_header Cache-Control \"public, immutable\";\n    }\n}\n

"},{"location":"v2/deployment/nginx/#mkdocs-static-site","title":"MkDocs Static Site","text":"

Build process (via admin GUI or CLI):

docker compose exec mkdocs mkdocs build\n

Output: mkdocs/site/ directory with static HTML

Served by mkdocs-site-server (Nginx Alpine container):

mkdocs-site-server:\n  image: lscr.io/linuxserver/nginx:latest\n  volumes:\n    - ./mkdocs/site:/config/www\n  ports:\n    - \"4004:80\"\n

Nginx config (in configs/mkdocs-site/default.conf):

server {\n    listen 80;\n    root /config/www;\n    index index.html;\n\n    location / {\n        try_files $uri $uri/ =404;\n    }\n}\n

"},{"location":"v2/deployment/nginx/#performance-optimization","title":"Performance Optimization","text":""},{"location":"v2/deployment/nginx/#gzip-compression_1","title":"Gzip Compression","text":"

Already enabled globally (see nginx.conf above).

Compression ratio: - JSON responses: ~75% reduction - HTML/CSS/JS: ~60-70% reduction - Images/video: No compression (already compressed)

Trade-off: Slight CPU increase (~5-10%) for bandwidth savings.

"},{"location":"v2/deployment/nginx/#caching-static-assets","title":"Caching Static Assets","text":"
location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {\n    expires 1y;\n    add_header Cache-Control \"public, immutable\";\n}\n

Effect: Browsers cache static assets for 1 year.

Caveat: Use content hashing in filenames (Vite does this automatically).

"},{"location":"v2/deployment/nginx/#proxy-caching","title":"Proxy Caching","text":"

Optional (not enabled by default):

# In http block\nproxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m max_size=1g inactive=60m;\n\n# In location block\nlocation /api/campaigns {\n    proxy_cache api_cache;\n    proxy_cache_valid 200 10m;\n    proxy_cache_key \"$scheme$request_method$host$request_uri\";\n    proxy_pass http://changemaker-v2-api:4000;\n}\n

Use cases: - Public campaign listing (10-minute cache) - Public map data (5-minute cache) - Representative lookup (1-hour cache)

Avoid caching: - Authenticated endpoints - POST/PUT/DELETE requests - Real-time data (canvass sessions, email queue)

"},{"location":"v2/deployment/nginx/#connection-pooling","title":"Connection Pooling","text":"

Keep-alive to backends:

upstream api {\n    server changemaker-v2-api:4000;\n    keepalive 32;  # Maintain 32 idle connections\n}\n\nlocation /api/ {\n    proxy_pass http://api;\n    proxy_http_version 1.1;\n    proxy_set_header Connection \"\";  # Clear close header\n}\n

Benefits: - Reduced latency (no TCP handshake) - Lower CPU (fewer connection setups) - Better throughput under load

"},{"location":"v2/deployment/nginx/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/nginx/#502-bad-gateway","title":"502 Bad Gateway","text":"

Symptoms: 502 Bad Gateway error

Causes: 1. Backend container not running 2. Backend healthcheck failing 3. Backend listening on wrong port 4. Network connectivity issue

Diagnosis:

# Check backend status\ndocker compose ps api\n\n# Check backend logs\ndocker compose logs --tail=50 api\n\n# Test backend directly\ndocker compose exec nginx curl http://changemaker-v2-api:4000/api/health\n\n# Check Nginx error log\ndocker compose exec nginx cat /var/log/nginx/error.log\n

Solution:

# Restart backend\ndocker compose restart api\n\n# Check healthcheck\ndocker inspect changemaker-v2-api | jq '.[0].State.Health'\n\n# Verify port in docker-compose.yml\ngrep -A5 \"api:\" docker-compose.yml\n

"},{"location":"v2/deployment/nginx/#504-gateway-timeout","title":"504 Gateway Timeout","text":"

Symptoms: Request times out after 60 seconds

Cause: Backend processing too slow, proxy timeout too short

Solution:

# Increase timeout for slow endpoints\nlocation /api/locations/geocode {\n    proxy_read_timeout 300s;  # 5 minutes\n    proxy_pass http://changemaker-v2-api:4000;\n}\n

"},{"location":"v2/deployment/nginx/#ssl-certificate-errors","title":"SSL Certificate Errors","text":"

Symptoms: SSL_ERROR_RX_RECORD_TOO_LONG or ERR_SSL_PROTOCOL_ERROR

Cause: Accessing HTTPS port via HTTP or vice versa

Diagnosis:

# Test HTTPS\ncurl -I https://api.cmlite.org\n\n# Check certificate\nopenssl s_client -connect api.cmlite.org:443 -servername api.cmlite.org\n\n# Verify Nginx config\ndocker compose exec nginx nginx -t\n

Solution:

# Reload Nginx after cert renewal\ndocker compose exec nginx nginx -s reload\n\n# Check cert paths in config\ngrep ssl_certificate /path/to/nginx/conf.d/*.conf\n

"},{"location":"v2/deployment/nginx/#cors-errors","title":"CORS Errors","text":"

Symptoms: Browser console shows CORS policy: No 'Access-Control-Allow-Origin' header

Cause: Backend not setting CORS headers

Diagnosis:

# Test from browser console\nfetch('http://api.cmlite.org/api/campaigns')\n\n# Check response headers\ncurl -H \"Origin: http://example.com\" -I http://api.cmlite.org/api/campaigns\n

Solution: CORS headers set by backend (not Nginx). Check api/src/server.ts:

app.use(cors({\n  origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'],\n  credentials: true,\n}));\n

Nginx passthrough (don't modify CORS headers):

# DO NOT add these in Nginx (backend handles CORS)\n# add_header Access-Control-Allow-Origin \"*\";  # \u274c WRONG\n

"},{"location":"v2/deployment/nginx/#websocket-connection-failures","title":"WebSocket Connection Failures","text":"

Symptoms: WebSocket upgrade fails with 400 Bad Request

Cause: Missing Upgrade/Connection headers

Diagnosis:

# Check Nginx config\ngrep -A5 \"Upgrade\" nginx/conf.d/services.conf\n\n# Test WebSocket\nwscat -c ws://localhost:5678\n

Solution:

# Add to location block\nproxy_set_header Upgrade $http_upgrade;\nproxy_set_header Connection \"upgrade\";\n

"},{"location":"v2/deployment/nginx/#large-upload-failures","title":"Large Upload Failures","text":"

Symptoms: Upload fails with 413 Request Entity Too Large

Cause: client_max_body_size too small

Solution:

# Increase limit for specific location\nlocation /api/media/videos {\n    client_max_body_size 10G;\n    proxy_pass http://changemaker-media-api:4100;\n}\n

"},{"location":"v2/deployment/nginx/#iframe-not-displaying","title":"Iframe Not Displaying","text":"

Symptoms: Service loads in new tab but not in iframe

Cause: X-Frame-Options: DENY or CSP frame-ancestors blocking

Diagnosis:

# Check response headers\ncurl -I http://db.cmlite.org\n\n# Look for X-Frame-Options or Content-Security-Policy\n

Solution:

# Hide backend's X-Frame-Options\nproxy_hide_header X-Frame-Options;\n\n# Add CSP allowing admin\nadd_header Content-Security-Policy \"frame-ancestors 'self' app.cmlite.org\" always;\n

"},{"location":"v2/deployment/nginx/#nginx-wont-start","title":"Nginx Won't Start","text":"

Symptoms: docker compose up fails with Nginx error

Diagnosis:

# Test config syntax\ndocker compose run --rm nginx nginx -t\n\n# Check for duplicate server_name\ngrep server_name nginx/conf.d/*.conf | sort\n\n# Check for port conflicts\ndocker compose config | grep -A2 \"ports:\"\n

Common mistakes: - Missing semicolon - Duplicate server_name (same subdomain in multiple files) - Invalid regex in location - Unclosed { bracket

"},{"location":"v2/deployment/nginx/#production-best-practices","title":"Production Best Practices","text":""},{"location":"v2/deployment/nginx/#rate-limiting","title":"Rate Limiting","text":"

Limit requests per IP (prevents abuse):

# In http block\nlimit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;\n\n# In location block\nlocation /api/ {\n    limit_req zone=api_limit burst=20 nodelay;\n    proxy_pass http://changemaker-v2-api:4000;\n}\n

Explanation: - rate=10r/s: 10 requests per second average - burst=20: Allow bursts up to 20 requests - nodelay: Process burst immediately (don't queue)

"},{"location":"v2/deployment/nginx/#security-headers-review","title":"Security Headers Review","text":"

Production checklist: - [x] HSTS enabled (max-age=31536000) - [x] X-Content-Type-Options: nosniff - [x] X-XSS-Protection: 1; mode=block - [x] CSP frame-ancestors for embeddable services - [x] X-Frame-Options: SAMEORIGIN for non-embedded services - [x] Referrer-Policy: strict-origin-when-cross-origin - [x] Permissions-Policy restricts sensors

Optional enhancements:

# Stricter CSP\nadd_header Content-Security-Policy \"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';\" always;\n\n# Expect-CT (certificate transparency)\nadd_header Expect-CT \"max-age=86400, enforce\" always;\n

"},{"location":"v2/deployment/nginx/#access-logging","title":"Access Logging","text":"

Production log format (JSON for parsing):

log_format json_combined escape=json\n  '{'\n    '\"time_local\":\"$time_local\",'\n    '\"remote_addr\":\"$remote_addr\",'\n    '\"request\":\"$request\",'\n    '\"status\": $status,'\n    '\"body_bytes_sent\":$body_bytes_sent,'\n    '\"request_time\":$request_time,'\n    '\"http_referrer\":\"$http_referer\",'\n    '\"http_user_agent\":\"$http_user_agent\"'\n  '}';\n\naccess_log /var/log/nginx/access.log json_combined;\n

Benefits: Easy parsing with tools like jq, Logstash, Loki.

"},{"location":"v2/deployment/nginx/#error-page-customization","title":"Error Page Customization","text":"

Custom error pages:

error_page 404 /404.html;\nerror_page 500 502 503 504 /50x.html;\n\nlocation = /404.html {\n    root /usr/share/nginx/html;\n    internal;\n}\n\nlocation = /50x.html {\n    root /usr/share/nginx/html;\n    internal;\n}\n

Create files:

cat > nginx/html/404.html <<EOF\n<!DOCTYPE html>\n<html>\n<head><title>404 Not Found</title></head>\n<body>\n<h1>404 - Page Not Found</h1>\n<p>Return to <a href=\"/\">homepage</a></p>\n</body>\n</html>\nEOF\n

"},{"location":"v2/deployment/nginx/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/deployment/scaling/","title":"Horizontal Scaling Strategies","text":""},{"location":"v2/deployment/scaling/#overview","title":"Overview","text":"

Changemaker Lite V2 can scale horizontally to handle increased traffic and data volume. This guide covers strategies for scaling each component.

When to Scale: - API response time >500ms (P95) - CPU usage >70% sustained - Memory usage >80% sustained - Database connection pool exhausted - Job queue backing up (>100 jobs waiting)

"},{"location":"v2/deployment/scaling/#database-scaling","title":"Database Scaling","text":""},{"location":"v2/deployment/scaling/#read-replicas","title":"Read Replicas","text":"

PostgreSQL streaming replication for read-heavy workloads.

Setup (docker-compose.yml):

v2-postgres-replica:\n  image: postgres:16-alpine\n  container_name: changemaker-v2-postgres-replica\n  environment:\n    POSTGRES_USER: replicator\n    POSTGRES_PASSWORD: ${REPLICA_PASSWORD}\n  command: |\n    postgres -c wal_level=replica\n             -c hot_standby=on\n             -c max_wal_senders=3\n             -c hot_standby_feedback=on\n  volumes:\n    - v2-postgres-replica-data:/var/lib/postgresql/data\n

Primary config (postgresql.conf):

wal_level = replica\nmax_wal_senders = 3\nwal_keep_size = 64MB\n

Replication user:

CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'replica-password';\n

Prisma read replica (planned feature):

// Future: Prisma read replicas\nconst prisma = new PrismaClient({\n  datasources: {\n    db: {\n      url: process.env.DATABASE_URL,           // Primary (writes)\n      replicaUrl: process.env.REPLICA_URL,     // Replica (reads)\n    },\n  },\n});\n

"},{"location":"v2/deployment/scaling/#connection-pooling","title":"Connection Pooling","text":"

PgBouncer for connection pooling.

docker-compose.yml:

pgbouncer:\n  image: pgbouncer/pgbouncer:latest\n  container_name: pgbouncer-changemaker\n  environment:\n    DATABASES_HOST: changemaker-v2-postgres\n    DATABASES_PORT: 5432\n    DATABASES_USER: changemaker\n    DATABASES_PASSWORD: ${V2_POSTGRES_PASSWORD}\n    DATABASES_DBNAME: changemaker_v2\n    POOL_MODE: transaction\n    MAX_CLIENT_CONN: 1000\n    DEFAULT_POOL_SIZE: 20\n  ports:\n    - \"6432:6432\"\n

Update DATABASE_URL:

# Before (direct)\nDATABASE_URL=postgresql://changemaker:pass@changemaker-v2-postgres:5432/changemaker_v2\n\n# After (pooled)\nDATABASE_URL=postgresql://changemaker:pass@pgbouncer:6432/changemaker_v2\n

Benefits: - Handles 1000+ client connections with only 20 PostgreSQL connections - Reduces connection overhead - Prevents \"too many connections\" errors

"},{"location":"v2/deployment/scaling/#api-scaling","title":"API Scaling","text":""},{"location":"v2/deployment/scaling/#multiple-api-containers","title":"Multiple API Containers","text":"

docker-compose.yml:

api:\n  # ... existing config\n  deploy:\n    replicas: 3  # Run 3 API containers\n

Or manual scaling:

docker compose up -d --scale api=3\n

Load balancer (Nginx upstream):

upstream api_backend {\n    least_conn;  # Load balancing algorithm\n    server changemaker-v2-api-1:4000;\n    server changemaker-v2-api-2:4000;\n    server changemaker-v2-api-3:4000;\n}\n\nserver {\n    location /api/ {\n        proxy_pass http://api_backend;\n    }\n}\n

Session affinity (sticky sessions):

upstream api_backend {\n    ip_hash;  # Route same IP to same backend\n    server changemaker-v2-api-1:4000;\n    server changemaker-v2-api-2:4000;\n}\n

"},{"location":"v2/deployment/scaling/#vertical-scaling-resource-limits","title":"Vertical Scaling (Resource Limits)","text":"

Increase container resources:

api:\n  deploy:\n    resources:\n      limits:\n        cpus: '4'      # 4 CPU cores\n        memory: 4G     # 4GB RAM\n      reservations:\n        cpus: '1'\n        memory: 1G\n

Node.js memory limit:

api:\n  environment:\n    - NODE_OPTIONS=--max-old-space-size=3072  # 3GB heap\n

"},{"location":"v2/deployment/scaling/#redis-scaling","title":"Redis Scaling","text":""},{"location":"v2/deployment/scaling/#redis-cluster-sharding","title":"Redis Cluster (Sharding)","text":"

For >100GB datasets or high throughput.

docker-compose.yml (6-node cluster):

redis-cluster-1:\n  image: redis:7-alpine\n  command: redis-server --cluster-enabled yes --cluster-config-file nodes.conf\n\n# ... repeat for redis-cluster-2 through redis-cluster-6\n

Create cluster:

docker compose exec redis-cluster-1 redis-cli --cluster create \\\n  redis-cluster-1:6379 \\\n  redis-cluster-2:6379 \\\n  redis-cluster-3:6379 \\\n  redis-cluster-4:6379 \\\n  redis-cluster-5:6379 \\\n  redis-cluster-6:6379 \\\n  --cluster-replicas 1\n

"},{"location":"v2/deployment/scaling/#redis-sentinel-high-availability","title":"Redis Sentinel (High Availability)","text":"

Automatic failover for Redis.

docker-compose.yml:

redis-sentinel-1:\n  image: redis:7-alpine\n  command: redis-sentinel /etc/redis/sentinel.conf\n  volumes:\n    - ./configs/redis/sentinel.conf:/etc/redis/sentinel.conf\n\n# ... repeat for sentinel-2, sentinel-3\n

sentinel.conf:

sentinel monitor mymaster redis-primary 6379 2\nsentinel down-after-milliseconds mymaster 5000\nsentinel parallel-syncs mymaster 1\nsentinel failover-timeout mymaster 10000\n

"},{"location":"v2/deployment/scaling/#media-api-scaling","title":"Media API Scaling","text":""},{"location":"v2/deployment/scaling/#separate-media-containers","title":"Separate Media Containers","text":"

docker-compose.yml:

media-api:\n  deploy:\n    replicas: 2  # Run 2 media API containers\n

Nginx load balancer:

upstream media_backend {\n    server changemaker-media-api-1:4100;\n    server changemaker-media-api-2:4100;\n}\n\nlocation /api/media/ {\n    proxy_pass http://media_backend;\n}\n

Shared volume (read-only):

media-api:\n  volumes:\n    - ${MEDIA_ROOT}:/media:ro  # All replicas read same library\n

"},{"location":"v2/deployment/scaling/#cdn-for-static-media","title":"CDN for Static Media","text":"

Cloudflare CDN (or similar):

Setup: 1. Enable Cloudflare proxy (orange cloud) 2. Configure cache rules: - Cache /media/library/*.mp4 for 30 days - Bypass cache for /api/media/ (dynamic)

Benefits: - Offload video bandwidth - Global edge caching - DDoS protection

"},{"location":"v2/deployment/scaling/#frontend-scaling","title":"Frontend Scaling","text":""},{"location":"v2/deployment/scaling/#cdn-for-static-assets","title":"CDN for Static Assets","text":"

Vite production build \u2192 static files \u2192 CDN.

Build:

cd admin && npm run build\n

Upload to CDN (S3 + CloudFront):

aws s3 sync dist/ s3://changemaker-static/ --delete\naws cloudfront create-invalidation --distribution-id XYZ --paths \"/*\"\n

Benefits: - Global edge caching - Reduced origin load - Faster page loads

"},{"location":"v2/deployment/scaling/#nginx-caching","title":"Nginx Caching","text":"

Proxy cache for API responses:

proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m max_size=1g inactive=60m;\n\nlocation /api/campaigns {\n    proxy_cache api_cache;\n    proxy_cache_valid 200 10m;\n    proxy_cache_key \"$scheme$request_method$host$request_uri\";\n    proxy_pass http://changemaker-v2-api:4000;\n}\n

Cacheable endpoints: - /api/campaigns (public listing, 10 minutes) - /api/representatives (lookup cache, 1 hour) - /api/locations/public (map data, 5 minutes)

Never cache: - POST/PUT/DELETE requests - Authenticated endpoints - Real-time data (canvass sessions)

"},{"location":"v2/deployment/scaling/#job-queue-scaling","title":"Job Queue Scaling","text":""},{"location":"v2/deployment/scaling/#multiple-bullmq-workers","title":"Multiple BullMQ Workers","text":"

API container scaling also scales workers (each container runs worker).

Alternative: Dedicated worker containers.

docker-compose.yml:

email-worker:\n  build:\n    context: ./api\n  container_name: email-worker\n  command: node dist/workers/email-worker.js\n  environment:\n    - REDIS_URL=${REDIS_URL}\n    - SMTP_HOST=${SMTP_HOST}\n    # ... other env vars\n  depends_on:\n    - redis\n

Worker script (api/src/workers/email-worker.ts):

import { emailQueue } from '../services/email-queue.service';\n\nemailQueue.process(10, async (job) => {\n  // Process email job\n});\n\nconsole.log('Email worker started');\n

Scale workers:

docker compose up -d --scale email-worker=5\n

"},{"location":"v2/deployment/scaling/#monitoring-under-load","title":"Monitoring Under Load","text":""},{"location":"v2/deployment/scaling/#load-testing","title":"Load Testing","text":"

k6 script (load-test.js):

import http from 'k6/http';\nimport { check } from 'k6';\n\nexport let options = {\n  stages: [\n    { duration: '1m', target: 50 },   // Ramp to 50 users\n    { duration: '3m', target: 50 },   // Stay at 50 users\n    { duration: '1m', target: 100 },  // Ramp to 100 users\n    { duration: '3m', target: 100 },  // Stay at 100 users\n    { duration: '1m', target: 0 },    // Ramp down\n  ],\n};\n\nexport default function () {\n  let res = http.get('http://api.cmlite.org/api/campaigns');\n  check(res, {\n    'status 200': (r) => r.status === 200,\n    'response time < 500ms': (r) => r.timings.duration < 500,\n  });\n}\n

Run test:

k6 run load-test.js\n

"},{"location":"v2/deployment/scaling/#prometheus-metrics","title":"Prometheus Metrics","text":"

Monitor scaling indicators: - rate(http_requests_total[5m]) \u2014 Request rate - histogram_quantile(0.95, http_request_duration_seconds) \u2014 P95 latency - container_cpu_usage_seconds_total \u2014 CPU usage per container - container_memory_usage_bytes \u2014 Memory usage per container

Grafana alert:

- alert: HighAPILatency\n  expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 0.5\n  for: 5m\n  labels:\n    severity: warning\n  annotations:\n    summary: \"P95 latency >500ms, consider scaling\"\n

"},{"location":"v2/deployment/scaling/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/scaling/#high-cpu-usage","title":"High CPU Usage","text":"

Diagnosis:

# Top processes\ndocker stats\n\n# API CPU usage\ndocker stats changemaker-v2-api\n\n# Profile Node.js\ndocker compose exec api node --prof dist/server.js\n

Solutions: - Scale API containers (3-5 replicas) - Increase CPU limit (2-4 cores) - Optimize slow queries (add indexes) - Enable caching (Nginx proxy cache)

"},{"location":"v2/deployment/scaling/#memory-leaks","title":"Memory Leaks","text":"

Diagnosis:

# Memory usage over time\ndocker stats --no-stream changemaker-v2-api\n\n# Heap snapshot (Node.js)\ndocker compose exec api node --inspect dist/server.js\n# Chrome DevTools \u2192 Memory \u2192 Take snapshot\n

Solutions: - Restart containers daily (cron job) - Increase memory limit (4-8GB) - Fix code leaks (event listeners, circular refs)

"},{"location":"v2/deployment/scaling/#database-connection-exhaustion","title":"Database Connection Exhaustion","text":"

Symptoms: Error: too many connections for role \"changemaker\"

Diagnosis:

# Check connection count\ndocker compose exec v2-postgres psql -U changemaker -c \\\n  \"SELECT COUNT(*) FROM pg_stat_activity WHERE usename='changemaker'\"\n\n# Check max connections\ndocker compose exec v2-postgres psql -U changemaker -c \\\n  \"SHOW max_connections\"\n

Solutions: - Add PgBouncer (connection pooling) - Increase max_connections (PostgreSQL config) - Fix connection leaks (always close Prisma clients)

"},{"location":"v2/deployment/scaling/#cost-optimization","title":"Cost Optimization","text":""},{"location":"v2/deployment/scaling/#resource-allocation","title":"Resource Allocation","text":"

Right-sizing (don't over-provision): - Start with 1 CPU, 1GB RAM per container - Monitor actual usage (Prometheus) - Scale based on metrics (not guesses)

Example (production workload): - API: 2 CPUs, 2GB RAM (3 replicas) - PostgreSQL: 2 CPUs, 4GB RAM - Redis: 1 CPU, 512MB RAM - Media API: 2 CPUs, 2GB RAM (2 replicas)

"},{"location":"v2/deployment/scaling/#autoscaling-docker-swarm","title":"Autoscaling (Docker Swarm)","text":"

Docker Swarm mode (alternative to Compose):

# Initialize swarm\ndocker swarm init\n\n# Deploy stack\ndocker stack deploy -c docker-compose.yml changemaker\n\n# Autoscale API\ndocker service scale changemaker_api=3\n\n# Update with zero downtime\ndocker service update --image api:v2.1 changemaker_api\n

Autoscaling:

api:\n  deploy:\n    replicas: 3\n    update_config:\n      parallelism: 1\n      delay: 10s\n    restart_policy:\n      condition: on-failure\n

"},{"location":"v2/deployment/scaling/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/deployment/ssl-tls/","title":"SSL/TLS Certificate Management","text":""},{"location":"v2/deployment/ssl-tls/#overview","title":"Overview","text":"

Changemaker Lite V2 supports multiple SSL/TLS certificate sources for HTTPS deployment:

Recommendation: Use Pangolin tunnel for simplest setup (SSL handled by tunnel provider).

"},{"location":"v2/deployment/ssl-tls/#certificate-sources","title":"Certificate Sources","text":""},{"location":"v2/deployment/ssl-tls/#lets-encrypt-with-certbot","title":"Let's Encrypt with Certbot","text":"

Best for: Self-hosted deployments with public DNS

Process: 1. Install Certbot 2. Generate certificate (DNS or HTTP challenge) 3. Configure Nginx 4. Auto-renewal via cron

Installation (Ubuntu/Debian):

sudo apt update\nsudo apt install certbot python3-certbot-nginx\n

Generate Certificate (HTTP-01 challenge):

# Stop Nginx temporarily\ndocker compose stop nginx\n\n# Generate cert\nsudo certbot certonly --standalone \\\n  -d cmlite.org \\\n  -d \"*.cmlite.org\" \\\n  --email admin@cmlite.org \\\n  --agree-tos \\\n  --non-interactive\n\n# Start Nginx\ndocker compose start nginx\n

Certificate Location:

/etc/letsencrypt/live/cmlite.org/\n\u251c\u2500 fullchain.pem  (certificate + intermediate)\n\u251c\u2500 privkey.pem    (private key)\n\u2514\u2500 chain.pem      (intermediate only)\n

Nginx Configuration:

server {\n    listen 443 ssl http2;\n    server_name api.cmlite.org;\n\n    ssl_certificate /etc/letsencrypt/live/cmlite.org/fullchain.pem;\n    ssl_certificate_key /etc/letsencrypt/live/cmlite.org/privkey.pem;\n\n    ssl_protocols TLSv1.2 TLSv1.3;\n    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256';\n    ssl_prefer_server_ciphers on;\n\n    # ... locations\n}\n

HTTP Redirect:

server {\n    listen 80;\n    server_name api.cmlite.org;\n    return 301 https://$host$request_uri;\n}\n

"},{"location":"v2/deployment/ssl-tls/#cloudflare-origin-certificates","title":"Cloudflare Origin Certificates","text":"

Best for: Sites using Cloudflare DNS + proxy

Process: 1. Generate certificate in Cloudflare dashboard 2. Download certificate + private key 3. Install in Nginx 4. Set SSL mode to \"Full (strict)\"

Generate Certificate: 1. Cloudflare dashboard \u2192 SSL/TLS \u2192 Origin Server 2. Click \"Create Certificate\" 3. Hostnames: cmlite.org, *.cmlite.org 4. Validity: 15 years 5. Download certificate + private key

Install Certificate:

# Create directory\nsudo mkdir -p /etc/ssl/cloudflare\n\n# Save files\nsudo nano /etc/ssl/cloudflare/cmlite.org.pem      # Certificate\nsudo nano /etc/ssl/cloudflare/cmlite.org.key      # Private key\n\n# Set permissions\nsudo chmod 600 /etc/ssl/cloudflare/cmlite.org.key\n

Nginx Configuration:

server {\n    listen 443 ssl http2;\n    server_name api.cmlite.org;\n\n    ssl_certificate /etc/ssl/cloudflare/cmlite.org.pem;\n    ssl_certificate_key /etc/ssl/cloudflare/cmlite.org.key;\n\n    # ... TLS config\n}\n

Cloudflare SSL Mode: Set to \"Full (strict)\" (not \"Flexible\").

"},{"location":"v2/deployment/ssl-tls/#pangolin-tunnel-ssl","title":"Pangolin Tunnel SSL","text":"

Best for: Quick deployment without SSL management

How it works: 1. Pangolin tunnel terminates SSL at tunnel endpoint 2. Traffic forwarded to your Nginx as HTTP 3. No certificate management needed

Setup:

# Configure tunnel (see Tunneling guide)\nPANGOLIN_ENDPOINT=https://pangolin.bnkserve.org\nNEWT_ID=<your-newt-id>\nNEWT_SECRET=<your-newt-secret>\n\n# Start Newt container\ndocker compose up -d newt\n

Nginx Configuration: Keep HTTP-only (tunnel handles HTTPS):

server {\n    listen 80;\n    server_name api.cmlite.org;\n\n    # No SSL config needed \u2014 tunnel terminates HTTPS\n    location / {\n        proxy_pass http://changemaker-v2-api:4000;\n    }\n}\n

DNS Setup: Point domain to tunnel endpoint (provided by Pangolin).

See Tunneling for complete guide.

"},{"location":"v2/deployment/ssl-tls/#nginx-ssl-configuration","title":"Nginx SSL Configuration","text":""},{"location":"v2/deployment/ssl-tls/#strong-tls-settings","title":"Strong TLS Settings","text":"
server {\n    listen 443 ssl http2;\n    server_name api.cmlite.org;\n\n    # Certificates\n    ssl_certificate /etc/letsencrypt/live/cmlite.org/fullchain.pem;\n    ssl_certificate_key /etc/letsencrypt/live/cmlite.org/privkey.pem;\n\n    # Protocols (TLS 1.2+ only)\n    ssl_protocols TLSv1.2 TLSv1.3;\n\n    # Ciphers (secure + fast)\n    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';\n    ssl_prefer_server_ciphers on;\n\n    # Session caching (performance)\n    ssl_session_cache shared:SSL:10m;\n    ssl_session_timeout 10m;\n\n    # OCSP stapling (performance + privacy)\n    ssl_stapling on;\n    ssl_stapling_verify on;\n    ssl_trusted_certificate /etc/letsencrypt/live/cmlite.org/chain.pem;\n\n    # HSTS (already set globally in nginx.conf)\n    # add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n\n    # ... locations\n}\n

Explanation: - TLS 1.2+: Disables insecure SSLv3, TLS 1.0/1.1 - Ciphers: ECDHE for forward secrecy, AES-GCM for speed - Session cache: Reduces TLS handshake overhead - OCSP stapling: Faster certificate validation

"},{"location":"v2/deployment/ssl-tls/#http2","title":"HTTP/2","text":"

Already enabled (:443 ssl http2): - Multiplexes requests over single connection - Server push support (optional) - Faster page loads

No additional config needed \u2014 Nginx handles HTTP/2 automatically.

"},{"location":"v2/deployment/ssl-tls/#hsts-http-strict-transport-security","title":"HSTS (HTTP Strict Transport Security)","text":"

Already set globally (in nginx/nginx.conf):

add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n

Effect: - Browsers cache HTTPS requirement for 1 year - Prevents downgrade attacks - Applies to all subdomains

Warning: Only enable after verifying HTTPS works (can't easily undo).

Test before enabling:

# Test HTTPS works\ncurl -I https://api.cmlite.org\n\n# Check for redirects\ncurl -L https://api.cmlite.org\n

"},{"location":"v2/deployment/ssl-tls/#certificate-renewal","title":"Certificate Renewal","text":""},{"location":"v2/deployment/ssl-tls/#automated-renewal-certbot","title":"Automated Renewal (Certbot)","text":"

Setup cron job:

# Edit crontab\nsudo crontab -e\n\n# Add renewal job (checks twice daily)\n0 0,12 * * * certbot renew --quiet --post-hook \"docker compose -f /path/to/changemaker.lite/docker-compose.yml exec nginx nginx -s reload\"\n

Manual renewal:

# Dry run (test)\nsudo certbot renew --dry-run\n\n# Real renewal\nsudo certbot renew\n\n# Reload Nginx\ndocker compose exec nginx nginx -s reload\n

Renewal conditions: - Certificates expire in <30 days - HTTP-01 challenge succeeds (port 80 must be open)

"},{"location":"v2/deployment/ssl-tls/#manual-renewal-cloudflare","title":"Manual Renewal (Cloudflare)","text":"

Cloudflare origin certificates valid for 15 years \u2014 no renewal needed.

If replacing certificate: 1. Generate new cert in Cloudflare dashboard 2. Download files 3. Replace files in /etc/ssl/cloudflare/ 4. Reload Nginx: docker compose exec nginx nginx -s reload

"},{"location":"v2/deployment/ssl-tls/#monitoring-expiry","title":"Monitoring Expiry","text":"

Check expiry date:

# Via OpenSSL\necho | openssl s_client -connect api.cmlite.org:443 -servername api.cmlite.org 2>/dev/null | openssl x509 -noout -dates\n\n# Output:\n# notBefore=Jan  1 00:00:00 2024 GMT\n# notAfter=Apr  1 23:59:59 2024 GMT\n

Automated monitoring (via Prometheus + Alertmanager):

# In alerts.yml\n- alert: SSLCertificateExpiringSoon\n  expr: probe_ssl_earliest_cert_expiry - time() < 86400 * 30  # 30 days\n  for: 1h\n  labels:\n    severity: warning\n  annotations:\n    summary: \"SSL certificate expiring in <30 days\"\n

"},{"location":"v2/deployment/ssl-tls/#testing-ssl","title":"Testing SSL","text":""},{"location":"v2/deployment/ssl-tls/#ssl-labs","title":"SSL Labs","text":"

Online test: https://www.ssllabs.com/ssltest/

Target grade: A or A+

Common issues: - Missing intermediate certificate (use fullchain.pem not cert.pem) - Weak ciphers (update ssl_ciphers list) - Missing HSTS header (already set globally)

"},{"location":"v2/deployment/ssl-tls/#command-line","title":"Command Line","text":"

Test TLS handshake:

openssl s_client -connect api.cmlite.org:443 -servername api.cmlite.org\n

Check certificate chain:

openssl s_client -connect api.cmlite.org:443 -servername api.cmlite.org -showcerts\n

Test specific protocol:

# TLS 1.2\nopenssl s_client -connect api.cmlite.org:443 -tls1_2\n\n# TLS 1.3\nopenssl s_client -connect api.cmlite.org:443 -tls1_3\n\n# SSLv3 (should fail)\nopenssl s_client -connect api.cmlite.org:443 -ssl3\n

"},{"location":"v2/deployment/ssl-tls/#wildcard-certificates","title":"Wildcard Certificates","text":"

For *.cmlite.org (covers all subdomains):

"},{"location":"v2/deployment/ssl-tls/#lets-encrypt-dns-01-challenge","title":"Let's Encrypt (DNS-01 Challenge)","text":"

Required: API access to DNS provider (Cloudflare, Route53, etc.)

Example (Cloudflare):

# Install Cloudflare plugin\nsudo apt install python3-certbot-dns-cloudflare\n\n# Create credentials file\ncat > ~/.secrets/cloudflare.ini <<EOF\ndns_cloudflare_api_token = YOUR_API_TOKEN\nEOF\nchmod 600 ~/.secrets/cloudflare.ini\n\n# Generate wildcard cert\nsudo certbot certonly \\\n  --dns-cloudflare \\\n  --dns-cloudflare-credentials ~/.secrets/cloudflare.ini \\\n  -d cmlite.org \\\n  -d \"*.cmlite.org\" \\\n  --email admin@cmlite.org \\\n  --agree-tos\n

Advantage: Single certificate covers all subdomains (api, app, db, etc.).

"},{"location":"v2/deployment/ssl-tls/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/ssl-tls/#certificate-not-trusted","title":"Certificate Not Trusted","text":"

Symptoms: Browser shows \"Not Secure\" warning

Causes: 1. Missing intermediate certificate 2. Wrong certificate file 3. Certificate expired

Solution:

# Use fullchain.pem (includes intermediate)\nssl_certificate /etc/letsencrypt/live/cmlite.org/fullchain.pem;\n\n# NOT cert.pem (missing intermediate)\n# ssl_certificate /etc/letsencrypt/live/cmlite.org/cert.pem;  # \u274c WRONG\n\n# Reload Nginx\ndocker compose exec nginx nginx -s reload\n

"},{"location":"v2/deployment/ssl-tls/#mixed-content-warnings","title":"Mixed Content Warnings","text":"

Symptoms: Some assets load via HTTP on HTTPS page

Cause: Hard-coded http:// URLs in HTML/JS

Solution:

// Change absolute URLs to protocol-relative\n// \u274c WRONG\nconst apiUrl = 'http://api.cmlite.org';\n\n// \u2705 CORRECT\nconst apiUrl = 'https://api.cmlite.org';\n\n// \u2705 BEST (protocol-relative)\nconst apiUrl = location.protocol + '//api.cmlite.org';\n

"},{"location":"v2/deployment/ssl-tls/#renewal-failures","title":"Renewal Failures","text":"

Symptoms: Certbot renewal fails

Diagnosis:

# Test renewal\nsudo certbot renew --dry-run\n\n# Check logs\nsudo tail -f /var/log/letsencrypt/letsencrypt.log\n

Common causes: - Port 80 blocked (HTTP-01 challenge fails) - DNS not pointing to server (domain validation fails) - Rate limit hit (5 certs/week per domain)

Solution:

# Check port 80 open\nsudo netstat -tulpn | grep :80\n\n# Test HTTP challenge\ncurl http://cmlite.org/.well-known/acme-challenge/test\n\n# Use DNS-01 challenge instead (no port 80 needed)\nsudo certbot certonly --dns-cloudflare ...\n

"},{"location":"v2/deployment/ssl-tls/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/deployment/tunneling/","title":"Pangolin Tunnel Deployment","text":""},{"location":"v2/deployment/tunneling/#overview","title":"Overview","text":"

Pangolin is a self-hosted tunnel service (alternative to Cloudflare Tunnel) that provides public HTTPS access to your Changemaker Lite instance without port forwarding or firewall configuration.

Benefits: - No port forwarding needed - SSL/TLS handled by tunnel provider - Static public URLs - Self-hosted tunnel server (privacy/control) - Free/open source

Architecture:

Internet \u2192 Pangolin Tunnel (pangolin.bnkserve.org) \u2192 Newt Container \u2192 Nginx \u2192 Services\n

Changemaker Integration: - Pangolin server: https://api.bnkserve.org/v1 - Tunnel endpoint: https://pangolin.bnkserve.org - Newt container: Tunnel connector (fosrl/newt image) - Admin GUI: PangolinPage.tsx setup wizard

"},{"location":"v2/deployment/tunneling/#setup-workflow","title":"Setup Workflow","text":""},{"location":"v2/deployment/tunneling/#1-prerequisites","title":"1. Prerequisites","text":"

Required: - Pangolin API key (obtain from Pangolin admin) - Docker Compose running - Nginx container accessible from Newt

Environment Variables:

PANGOLIN_API_URL=https://api.bnkserve.org/v1\nPANGOLIN_API_KEY=<your-api-key>\nPANGOLIN_ORG_ID=<set-after-org-creation>\nPANGOLIN_SITE_ID=<set-after-site-creation>\nPANGOLIN_ENDPOINT=https://pangolin.bnkserve.org\nPANGOLIN_NEWT_ID=<set-after-resource-creation>\nPANGOLIN_NEWT_SECRET=<set-after-resource-creation>\n

"},{"location":"v2/deployment/tunneling/#2-setup-via-admin-gui","title":"2. Setup via Admin GUI","text":"

Easiest method: Use /app/pangolin page in admin GUI.

Steps: 1. Navigate to http://localhost:3000/app/pangolin 2. Enter PANGOLIN_API_KEY (click \"Test Connection\") 3. Create Organization (or select existing) 4. Create Site (linked to org) 5. Create Endpoint (tunnel URL) 6. Create Resource (Newt connector credentials) 7. Copy NEWT_ID and NEWT_SECRET to .env 8. Restart Newt container: docker compose restart newt

"},{"location":"v2/deployment/tunneling/#3-manual-setup-cli","title":"3. Manual Setup (CLI)","text":"

Organization:

curl -X POST https://api.bnkserve.org/v1/orgs \\\n  -H \"Authorization: Bearer $PANGOLIN_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"name\": \"My Organization\",\n    \"description\": \"Changemaker Lite Production\"\n  }'\n\n# Returns:\n# {\"id\":\"org_abc123\",\"name\":\"My Organization\",...}\n\nexport PANGOLIN_ORG_ID=org_abc123\n

Site:

curl -X POST https://api.bnkserve.org/v1/sites \\\n  -H \"Authorization: Bearer $PANGOLIN_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"org_id\": \"'\"$PANGOLIN_ORG_ID\"'\",\n    \"name\": \"Production Site\",\n    \"description\": \"Main deployment\"\n  }'\n\n# Returns:\n# {\"id\":\"site_xyz789\",\"name\":\"Production Site\",...}\n\nexport PANGOLIN_SITE_ID=site_xyz789\n

Endpoint:

curl -X POST https://api.bnkserve.org/v1/endpoints \\\n  -H \"Authorization: Bearer $PANGOLIN_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"site_id\": \"'\"$PANGOLIN_SITE_ID\"'\",\n    \"subdomain\": \"changemaker\",\n    \"domain\": \"pangolin.bnkserve.org\"\n  }'\n\n# Returns:\n# {\"id\":\"endpoint_def456\",\"url\":\"https://changemaker.pangolin.bnkserve.org\",...}\n\nexport PANGOLIN_ENDPOINT=https://changemaker.pangolin.bnkserve.org\n

Resource (Newt Connector):

curl -X POST https://api.bnkserve.org/v1/resources \\\n  -H \"Authorization: Bearer $PANGOLIN_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"endpoint_id\": \"<endpoint-id>\",\n    \"target\": \"http://nginx:80\",\n    \"name\": \"Changemaker Services\"\n  }'\n\n# Returns:\n# {\"id\":\"newt_abc123\",\"secret\":\"secret_xyz789\",...}\n\nexport PANGOLIN_NEWT_ID=newt_abc123\nexport PANGOLIN_NEWT_SECRET=secret_xyz789\n

Update .env:

cat >> .env <<EOF\nPANGOLIN_ORG_ID=$PANGOLIN_ORG_ID\nPANGOLIN_SITE_ID=$PANGOLIN_SITE_ID\nPANGOLIN_ENDPOINT=$PANGOLIN_ENDPOINT\nPANGOLIN_NEWT_ID=$PANGOLIN_NEWT_ID\nPANGOLIN_NEWT_SECRET=$PANGOLIN_NEWT_SECRET\nEOF\n

"},{"location":"v2/deployment/tunneling/#4-start-newt-container","title":"4. Start Newt Container","text":"
# Restart to pick up new env vars\ndocker compose up -d --force-recreate newt\n\n# Check logs\ndocker compose logs -f newt\n\n# Should see:\n# [INFO] Connected to Pangolin tunnel\n# [INFO] Tunnel active: https://changemaker.pangolin.bnkserve.org\n
"},{"location":"v2/deployment/tunneling/#5-dns-configuration","title":"5. DNS Configuration","text":"

Option A: Direct Access (use tunnel URL): - https://changemaker.pangolin.bnkserve.org

Option B: Custom Domain (CNAME to tunnel):

app.cmlite.org.  CNAME  changemaker.pangolin.bnkserve.org.\napi.cmlite.org.  CNAME  changemaker.pangolin.bnkserve.org.\n

"},{"location":"v2/deployment/tunneling/#newt-container-configuration","title":"Newt Container Configuration","text":"

docker-compose.yml:

newt:\n  image: fosrl/newt\n  container_name: newt-changemaker\n  restart: unless-stopped\n  environment:\n    - PANGOLIN_ENDPOINT=${PANGOLIN_ENDPOINT}\n    - NEWT_ID=${PANGOLIN_NEWT_ID}\n    - NEWT_SECRET=${PANGOLIN_NEWT_SECRET}\n  depends_on:\n    - nginx\n  networks:\n    - changemaker-lite\n

Key Features: - Restart policy: unless-stopped (auto-reconnects) - Nginx dependency: Ensures Nginx running before Newt starts - No ports exposed: All traffic via tunnel

Target: Newt connects to http://nginx:80 (Docker internal network).

"},{"location":"v2/deployment/tunneling/#tunnel-lifecycle","title":"Tunnel Lifecycle","text":""},{"location":"v2/deployment/tunneling/#start-tunnel","title":"Start Tunnel","text":"
docker compose up -d newt\n
"},{"location":"v2/deployment/tunneling/#stop-tunnel","title":"Stop Tunnel","text":"
docker compose stop newt\n
"},{"location":"v2/deployment/tunneling/#check-status","title":"Check Status","text":"
# Container status\ndocker compose ps newt\n\n# Logs\ndocker compose logs --tail=50 newt\n\n# Test tunnel\ncurl https://changemaker.pangolin.bnkserve.org/api/health\n
"},{"location":"v2/deployment/tunneling/#restart-after-env-changes","title":"Restart (after .env changes)","text":"
docker compose up -d --force-recreate newt\n
"},{"location":"v2/deployment/tunneling/#exit-nodes-resource-routing","title":"Exit Nodes & Resource Routing","text":"

Resource: Defines how tunnel routes traffic to your services.

Target URL: http://nginx:80 (Nginx handles subdomain routing internally).

Example Flow: 1. User visits https://changemaker.pangolin.bnkserve.org/api/health 2. Pangolin tunnel receives HTTPS request 3. Tunnel forwards to Newt container 4. Newt proxies to http://nginx:80/api/health 5. Nginx routes to changemaker-v2-api:4000/api/health 6. Response flows back through tunnel

Multiple Resources: Create separate resources for different backends (advanced).

"},{"location":"v2/deployment/tunneling/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/tunneling/#tunnel-not-connecting","title":"Tunnel Not Connecting","text":"

Symptoms: Newt logs show connection errors

Diagnosis:

docker compose logs newt\n\n# Common errors:\n# - \"Authentication failed\" (wrong NEWT_ID/SECRET)\n# - \"Endpoint not found\" (wrong PANGOLIN_ENDPOINT)\n# - \"Connection refused\" (Nginx not running)\n

Solutions:

# Verify credentials\ndocker compose exec newt printenv | grep PANGOLIN\n\n# Test Nginx from Newt container\ndocker compose exec newt wget -O- http://nginx:80/api/health\n\n# Restart Newt\ndocker compose restart newt\n

"},{"location":"v2/deployment/tunneling/#tunnel-connected-but-site-unreachable","title":"Tunnel Connected But Site Unreachable","text":"

Symptoms: Newt connected, but HTTPS requests timeout/fail

Diagnosis:

# Test tunnel endpoint\ncurl -I https://changemaker.pangolin.bnkserve.org\n\n# Check Nginx logs\ndocker compose logs nginx | tail -50\n\n# Verify resource target\ncurl -X GET https://api.bnkserve.org/v1/resources/<resource-id> \\\n  -H \"Authorization: Bearer $PANGOLIN_API_KEY\"\n

Common Causes: - Resource target points to wrong service - Nginx not listening on port 80 - Firewall blocking Nginx \u2192 backend communication

Solution:

# Verify Nginx config\ndocker compose exec nginx nginx -t\n\n# Check Nginx listening\ndocker compose exec nginx netstat -tulpn | grep :80\n\n# Test backend from Nginx\ndocker compose exec nginx curl http://changemaker-v2-api:4000/api/health\n

"},{"location":"v2/deployment/tunneling/#ssl-certificate-errors","title":"SSL Certificate Errors","text":"

Symptoms: Browser shows \"Certificate invalid\" warning

Cause: Tunnel endpoint SSL certificate not trusted (rare).

Solution: Contact Pangolin support \u2014 tunnel provider manages SSL certificates.

"},{"location":"v2/deployment/tunneling/#frequent-disconnects","title":"Frequent Disconnects","text":"

Symptoms: Newt reconnects every few minutes

Diagnosis:

# Check for network issues\ndocker compose logs newt | grep -i disconnect\n\n# Monitor connection\nwatch -n5 'docker compose logs --tail=1 newt'\n

Possible Causes: - Network instability - Container restarts (check docker compose ps) - Resource limits (check docker stats newt-changemaker)

Solution:

# Increase restart backoff (if needed)\n# Edit docker-compose.yml:\nnewt:\n  restart_policy:\n    condition: on-failure\n    delay: 5s\n    max_attempts: 3\n

"},{"location":"v2/deployment/tunneling/#migration-from-cloudflare-tunnel","title":"Migration from Cloudflare Tunnel","text":"

Retired Scripts (in scripts/legacy/): - start-production.sh - config.sh - tunnel-config.sh

Migration Steps: 1. Stop Cloudflare tunnel: cloudflared service uninstall 2. Remove Cloudflare credentials: rm ~/.cloudflared/*.json 3. Setup Pangolin tunnel (see above) 4. Update DNS: Change CNAME from cloudflared.com to pangolin.bnkserve.org 5. Test new tunnel: curl https://changemaker.pangolin.bnkserve.org/api/health 6. Remove old scripts: rm scripts/legacy/*

Why Pangolin? - Self-hosted (privacy/control) - No Cloudflare dependency - Free/open source - API-driven management

"},{"location":"v2/deployment/tunneling/#advanced-configuration","title":"Advanced Configuration","text":""},{"location":"v2/deployment/tunneling/#custom-tunnel-domain","title":"Custom Tunnel Domain","text":"

Requirement: Own domain with DNS control.

Steps: 1. Create endpoint with custom domain 2. Add DNS record: tunnel.cmlite.org CNAME pangolin.bnkserve.org. 3. Update PANGOLIN_ENDPOINT=https://tunnel.cmlite.org 4. Restart Newt

"},{"location":"v2/deployment/tunneling/#multiple-sites","title":"Multiple Sites","text":"

Use case: Staging + production on same tunnel.

Setup:

# Create second site\ncurl -X POST https://api.bnkserve.org/v1/sites \\\n  -d '{\"org_id\":\"...\",\"name\":\"Staging\"}'\n\n# Create endpoint for staging\ncurl -X POST https://api.bnkserve.org/v1/endpoints \\\n  -d '{\"site_id\":\"...\",\"subdomain\":\"staging-changemaker\"}'\n\n# Create resource pointing to staging Nginx\ncurl -X POST https://api.bnkserve.org/v1/resources \\\n  -d '{\"endpoint_id\":\"...\",\"target\":\"http://nginx-staging:80\"}'\n

"},{"location":"v2/deployment/tunneling/#monitoring","title":"Monitoring","text":""},{"location":"v2/deployment/tunneling/#health-checks","title":"Health Checks","text":"

Tunnel status:

# Container health\ndocker compose ps newt\n\n# Connection logs\ndocker compose logs --tail=50 newt | grep -i connected\n\n# Test public endpoint\ncurl -I https://changemaker.pangolin.bnkserve.org\n

Prometheus metrics (if enabled):

# API uptime through tunnel\ncurl https://changemaker.pangolin.bnkserve.org/api/metrics | grep cm_api_uptime\n

"},{"location":"v2/deployment/tunneling/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/development/","title":"Development Guide","text":"

This section covers development workflows, local setup, coding standards, testing, and best practices for contributing to Changemaker Lite V2.

"},{"location":"v2/development/#development-workflow","title":"Development Workflow","text":""},{"location":"v2/development/#local-setup","title":"Local Setup","text":"

Getting started with local development:

"},{"location":"v2/development/#docker-workflow","title":"Docker Workflow","text":"

Docker-based development:

"},{"location":"v2/development/#git-workflow","title":"Git Workflow","text":"

Version control best practices:

"},{"location":"v2/development/#npm-commands","title":"NPM Commands","text":"

Common development commands:

"},{"location":"v2/development/#database-migrations","title":"Database Migrations","text":"

Schema changes and migrations:

"},{"location":"v2/development/#typescript","title":"TypeScript","text":"

TypeScript best practices:

"},{"location":"v2/development/#code-style","title":"Code Style","text":"

Coding standards and conventions:

"},{"location":"v2/development/#testing","title":"Testing","text":"

Testing strategies:

"},{"location":"v2/development/#debugging","title":"Debugging","text":"

Debugging techniques:

"},{"location":"v2/development/#quick-start","title":"Quick Start","text":""},{"location":"v2/development/#local-development-no-docker","title":"Local Development (No Docker)","text":"

Terminal 1: API Server

cd api\nnpm install\nnpm run dev\n# Runs on http://localhost:4000\n

Terminal 2: Admin GUI

cd admin\nnpm install\nnpm run dev\n# Runs on http://localhost:3000\n

Terminal 3: Media API (Optional)

cd api\nnpm run dev:media\n# Runs on http://localhost:4100\n

"},{"location":"v2/development/#docker-development","title":"Docker Development","text":"

Start Core Services

docker compose up -d v2-postgres redis\ndocker compose up -d api admin\n

View Logs

docker compose logs -f api\ndocker compose logs -f admin\n

Rebuild After Changes

docker compose build api\ndocker compose up -d api\n

"},{"location":"v2/development/#development-tools","title":"Development Tools","text":""},{"location":"v2/development/#required","title":"Required","text":""},{"location":"v2/development/#recommended","title":"Recommended","text":""},{"location":"v2/development/#vs-code-extensions","title":"VS Code Extensions","text":""},{"location":"v2/development/#project-structure","title":"Project Structure","text":"
changemaker.lite/\n\u251c\u2500\u2500 api/                    # Backend (Express + Fastify)\n\u2502   \u251c\u2500\u2500 src/\n\u2502   \u2502   \u251c\u2500\u2500 server.ts       # Express entry point\n\u2502   \u2502   \u251c\u2500\u2500 media-server.ts # Fastify entry point\n\u2502   \u2502   \u251c\u2500\u2500 modules/        # Feature modules\n\u2502   \u2502   \u251c\u2500\u2500 services/       # Shared services\n\u2502   \u2502   \u251c\u2500\u2500 middleware/     # Express middleware\n\u2502   \u2502   \u2514\u2500\u2500 utils/          # Utilities\n\u2502   \u251c\u2500\u2500 prisma/\n\u2502   \u2502   \u251c\u2500\u2500 schema.prisma   # Main schema\n\u2502   \u2502   \u2514\u2500\u2500 migrations/     # Migration history\n\u2502   \u2514\u2500\u2500 package.json\n\u2502\n\u251c\u2500\u2500 admin/                  # Frontend (React + Vite)\n\u2502   \u251c\u2500\u2500 src/\n\u2502   \u2502   \u251c\u2500\u2500 App.tsx         # Main router\n\u2502   \u2502   \u251c\u2500\u2500 components/     # Shared components\n\u2502   \u2502   \u251c\u2500\u2500 pages/          # Page components\n\u2502   \u2502   \u251c\u2500\u2500 lib/            # API clients\n\u2502   \u2502   \u2514\u2500\u2500 stores/         # Zustand stores\n\u2502   \u2514\u2500\u2500 package.json\n\u2502\n\u251c\u2500\u2500 docker-compose.yml      # V2 orchestration\n\u251c\u2500\u2500 .env                    # Environment variables (not committed)\n\u2514\u2500\u2500 .env.example            # Example environment\n
"},{"location":"v2/development/#development-patterns","title":"Development Patterns","text":""},{"location":"v2/development/#backend-module-structure","title":"Backend Module Structure","text":"
api/src/modules/feature/\n\u251c\u2500\u2500 feature.routes.ts       # Express router\n\u251c\u2500\u2500 feature.service.ts      # Business logic\n\u251c\u2500\u2500 feature.schemas.ts      # Zod validation\n\u2514\u2500\u2500 feature-public.routes.ts # Public routes (optional)\n
"},{"location":"v2/development/#frontend-page-structure","title":"Frontend Page Structure","text":"
admin/src/pages/\n\u251c\u2500\u2500 admin/                  # Admin pages (30)\n\u251c\u2500\u2500 public/                 # Public pages (8)\n\u251c\u2500\u2500 volunteer/              # Volunteer pages (4)\n\u2514\u2500\u2500 auth/                   # Auth pages (1)\n
"},{"location":"v2/development/#api-client-pattern","title":"API Client Pattern","text":"
// admin/src/lib/api.ts\nimport axios from 'axios';\n\nexport const api = axios.create({\n  baseURL: import.meta.env.VITE_API_URL,\n});\n\n// Interceptor for auth\napi.interceptors.request.use((config) => {\n  const token = localStorage.getItem('accessToken');\n  if (token) {\n    config.headers.Authorization = `Bearer ${token}`;\n  }\n  return config;\n});\n
"},{"location":"v2/development/#service-pattern","title":"Service Pattern","text":"
// api/src/modules/feature/feature.service.ts\nimport { prisma } from '../../lib/prisma';\n\nclass FeatureService {\n  async create(data: CreateInput) {\n    return await prisma.feature.create({ data });\n  }\n\n  async findAll(filters: Filters) {\n    return await prisma.feature.findMany({ where: filters });\n  }\n}\n\nexport const featureService = new FeatureService();\n
"},{"location":"v2/development/#common-tasks","title":"Common Tasks","text":""},{"location":"v2/development/#add-new-api-endpoint","title":"Add New API Endpoint","text":"
  1. Create schema in *.schemas.ts
  2. Add service method in *.service.ts
  3. Add route handler in *.routes.ts
  4. Register router in server.ts
  5. Test with Postman/curl
"},{"location":"v2/development/#add-new-page","title":"Add New Page","text":"
  1. Create page component in pages/
  2. Add route in App.tsx
  3. Add to sidebar menu (if admin page)
  4. Create API client calls
  5. Test in browser
"},{"location":"v2/development/#add-database-field","title":"Add Database Field","text":"
  1. Update prisma/schema.prisma
  2. Run npx prisma migrate dev --name add_field
  3. Update TypeScript types
  4. Update API endpoints
  5. Update frontend forms
"},{"location":"v2/development/#add-new-service-integration","title":"Add New Service Integration","text":"
  1. Create client in services/
  2. Add environment variables
  3. Create admin routes
  4. Add admin page
  5. Test integration
"},{"location":"v2/development/#testing_1","title":"Testing","text":""},{"location":"v2/development/#api-tests","title":"API Tests","text":"
# Run API tests\ncd api && npm test\n\n# Test specific endpoint\ncurl http://localhost:4000/api/campaigns\n
"},{"location":"v2/development/#frontend-tests","title":"Frontend Tests","text":"
# Run component tests\ncd admin && npm test\n\n# Test build\ncd admin && npm run build\n
"},{"location":"v2/development/#type-checking","title":"Type Checking","text":"
# Check API types\ncd api && npx tsc --noEmit\n\n# Check admin types\ncd admin && npx tsc --noEmit\n
"},{"location":"v2/development/#debugging_1","title":"Debugging","text":""},{"location":"v2/development/#api-debugging","title":"API Debugging","text":"
# View API logs\ndocker compose logs -f api\n\n# Access container shell\ndocker compose exec api sh\n\n# Run Prisma Studio\ncd api && npx prisma studio\n
"},{"location":"v2/development/#frontend-debugging","title":"Frontend Debugging","text":"
# View admin logs\ndocker compose logs -f admin\n\n# Access browser DevTools\n# Open http://localhost:3000\n# F12 for DevTools\n
"},{"location":"v2/development/#code-quality","title":"Code Quality","text":""},{"location":"v2/development/#linting","title":"Linting","text":"
# Lint API\ncd api && npm run lint\n\n# Lint admin\ncd admin && npm run lint\n
"},{"location":"v2/development/#formatting","title":"Formatting","text":"
# Format API\ncd api && npm run format\n\n# Format admin\ncd admin && npm run format\n
"},{"location":"v2/development/#type-safety","title":"Type Safety","text":"

All code uses TypeScript with strict mode:

{\n  \"compilerOptions\": {\n    \"strict\": true,\n    \"noImplicitAny\": true,\n    \"strictNullChecks\": true\n  }\n}\n
"},{"location":"v2/development/#best-practices","title":"Best Practices","text":""},{"location":"v2/development/#backend","title":"Backend","text":""},{"location":"v2/development/#frontend","title":"Frontend","text":""},{"location":"v2/development/#database","title":"Database","text":""},{"location":"v2/development/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/development/code-style/","title":"Code Style Guide","text":"

Coding standards and style conventions for Changemaker Lite V2.

"},{"location":"v2/development/code-style/#overview","title":"Overview","text":"

Consistent code style improves: - Readability: Easier to understand code - Maintainability: Easier to modify code - Collaboration: Reduces merge conflicts - Quality: Catches common errors

This guide covers TypeScript, ESLint, Prettier, and naming conventions.

"},{"location":"v2/development/code-style/#tools","title":"Tools","text":""},{"location":"v2/development/code-style/#typescript","title":"TypeScript","text":"

Version: 5.x Config: tsconfig.json (api/ and admin/)

Strict Mode: Enabled

{\n  \"compilerOptions\": {\n    \"strict\": true,\n    \"noImplicitAny\": true,\n    \"strictNullChecks\": true,\n    \"strictFunctionTypes\": true,\n    \"strictPropertyInitialization\": true,\n    \"noImplicitThis\": true,\n    \"alwaysStrict\": true\n  }\n}\n
"},{"location":"v2/development/code-style/#eslint","title":"ESLint","text":"

Version: 8.x Config: .eslintrc.js (api/ and admin/)

Plugins: - @typescript-eslint/eslint-plugin - eslint-plugin-react (admin only) - eslint-plugin-react-hooks (admin only)

"},{"location":"v2/development/code-style/#prettier","title":"Prettier","text":"

Version: 3.x Config: .prettierrc

Format on save: Enabled (VSCode)

"},{"location":"v2/development/code-style/#typescript-configuration","title":"TypeScript Configuration","text":""},{"location":"v2/development/code-style/#api-tsconfigjson","title":"API tsconfig.json","text":"
{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"commonjs\",\n    \"lib\": [\"ES2022\"],\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\",\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"skipLibCheck\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"resolveJsonModule\": true,\n    \"moduleResolution\": \"node\",\n    \"types\": [\"node\"]\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\"node_modules\", \"dist\", \"**/*.test.ts\"]\n}\n
"},{"location":"v2/development/code-style/#admin-tsconfigjson","title":"Admin tsconfig.json","text":"
{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"types\": [\"vite/client\"]\n  },\n  \"include\": [\"src\"],\n  \"references\": [{ \"path\": \"./tsconfig.node.json\" }]\n}\n
"},{"location":"v2/development/code-style/#eslint-rules","title":"ESLint Rules","text":""},{"location":"v2/development/code-style/#api-eslintrcjs","title":"API .eslintrc.js","text":"
module.exports = {\n  parser: '@typescript-eslint/parser',\n  parserOptions: {\n    ecmaVersion: 2022,\n    sourceType: 'module',\n    project: './tsconfig.json'\n  },\n  extends: [\n    'eslint:recommended',\n    'plugin:@typescript-eslint/recommended',\n    'plugin:@typescript-eslint/recommended-requiring-type-checking'\n  ],\n  plugins: ['@typescript-eslint'],\n  root: true,\n  env: {\n    node: true,\n    es2022: true\n  },\n  rules: {\n    // TypeScript\n    '@typescript-eslint/no-explicit-any': 'error',\n    '@typescript-eslint/explicit-function-return-type': 'off',\n    '@typescript-eslint/explicit-module-boundary-types': 'off',\n    '@typescript-eslint/no-unused-vars': [\n      'error',\n      { argsIgnorePattern: '^_' }\n    ],\n    '@typescript-eslint/no-floating-promises': 'error',\n    '@typescript-eslint/await-thenable': 'error',\n\n    // General\n    'no-console': ['warn', { allow: ['warn', 'error'] }],\n    'no-debugger': 'error',\n    'prefer-const': 'error',\n    'no-var': 'error',\n    'eqeqeq': ['error', 'always'],\n    'curly': ['error', 'all']\n  }\n};\n
"},{"location":"v2/development/code-style/#admin-eslintrcjs","title":"Admin .eslintrc.js","text":"
module.exports = {\n  parser: '@typescript-eslint/parser',\n  parserOptions: {\n    ecmaVersion: 2020,\n    sourceType: 'module',\n    ecmaFeatures: {\n      jsx: true\n    },\n    project: './tsconfig.json'\n  },\n  extends: [\n    'eslint:recommended',\n    'plugin:react/recommended',\n    'plugin:react-hooks/recommended',\n    'plugin:@typescript-eslint/recommended'\n  ],\n  plugins: ['react', 'react-hooks', '@typescript-eslint'],\n  root: true,\n  env: {\n    browser: true,\n    es2020: true\n  },\n  settings: {\n    react: {\n      version: 'detect'\n    }\n  },\n  rules: {\n    // React\n    'react/react-in-jsx-scope': 'off', // React 17+\n    'react/prop-types': 'off', // Use TypeScript\n    'react-hooks/rules-of-hooks': 'error',\n    'react-hooks/exhaustive-deps': 'warn',\n\n    // TypeScript\n    '@typescript-eslint/no-explicit-any': 'error',\n    '@typescript-eslint/explicit-function-return-type': 'off',\n    '@typescript-eslint/no-unused-vars': [\n      'error',\n      { argsIgnorePattern: '^_' }\n    ],\n\n    // General\n    'no-console': ['warn', { allow: ['warn', 'error'] }],\n    'no-debugger': 'error',\n    'prefer-const': 'error',\n    'no-var': 'error',\n    'eqeqeq': ['error', 'always']\n  }\n};\n
"},{"location":"v2/development/code-style/#key-rules-explained","title":"Key Rules Explained","text":"

@typescript-eslint/no-explicit-any - Prevents any type

// \u274c Bad\nfunction foo(data: any) {}\n\n// \u2705 Good\nfunction foo(data: User) {}\nfunction foo(data: unknown) {} // Use unknown instead\n

@typescript-eslint/no-unused-vars - Prevents unused variables

// \u274c Bad\nconst foo = 1; // Never used\n\n// \u2705 Good\nconst _foo = 1; // Prefix with _ to ignore\n

@typescript-eslint/no-floating-promises - Requires await/catch

// \u274c Bad\nasyncFunction(); // Promise not handled\n\n// \u2705 Good\nawait asyncFunction();\nasyncFunction().catch(console.error);\nvoid asyncFunction(); // Explicitly ignore\n

react-hooks/exhaustive-deps - Validates useEffect dependencies

// \u274c Bad\nuseEffect(() => {\n  fetchUser(userId);\n}, []); // Missing userId dependency\n\n// \u2705 Good\nuseEffect(() => {\n  fetchUser(userId);\n}, [userId]);\n

"},{"location":"v2/development/code-style/#prettier-configuration","title":"Prettier Configuration","text":""},{"location":"v2/development/code-style/#prettierrc","title":".prettierrc","text":"
{\n  \"semi\": true,\n  \"singleQuote\": true,\n  \"tabWidth\": 2,\n  \"useTabs\": false,\n  \"trailingComma\": \"es5\",\n  \"printWidth\": 100,\n  \"arrowParens\": \"avoid\",\n  \"endOfLine\": \"lf\"\n}\n
"},{"location":"v2/development/code-style/#prettierignore","title":".prettierignore","text":"
node_modules\ndist\nbuild\ncoverage\n.vite\n.cache\n*.min.js\n*.min.css\npackage-lock.json\n
"},{"location":"v2/development/code-style/#format-commands","title":"Format Commands","text":"
# Format all files\nnpm run format\n\n# Check formatting (CI)\nnpm run format:check\n\n# Format specific file\nnpx prettier --write src/modules/auth/auth.service.ts\n
"},{"location":"v2/development/code-style/#naming-conventions","title":"Naming Conventions","text":""},{"location":"v2/development/code-style/#files-and-directories","title":"Files and Directories","text":"

Files: kebab-case

auth.service.ts\nuser.controller.ts\ncampaign.routes.ts\nlocations-page.tsx\n

Components: PascalCase

UserCard.tsx\nLoginForm.tsx\nMapView.tsx\n

Test files: Match source file with .test or .spec

auth.service.test.ts\nUserCard.test.tsx\n

Directories: kebab-case

src/modules/auth/\nsrc/components/map/\nsrc/pages/public/\n

"},{"location":"v2/development/code-style/#variables-and-functions","title":"Variables and Functions","text":"

Variables: camelCase

const userName = 'John';\nconst isActive = true;\nconst totalCount = 100;\n

Constants: UPPER_SNAKE_CASE

const API_URL = 'http://localhost:4000';\nconst MAX_RETRIES = 3;\nconst DEFAULT_PAGE_SIZE = 50;\n

Functions: camelCase

function getUserById(id: number) {}\nasync function fetchCampaigns() {}\nconst handleClick = () => {};\n

Private methods: Prefix with underscore (optional)

class UserService {\n  async getUser(id: number) {}\n\n  private async _hashPassword(password: string) {}\n}\n

"},{"location":"v2/development/code-style/#types-and-interfaces","title":"Types and Interfaces","text":"

Types/Interfaces: PascalCase

interface User {\n  id: number;\n  email: string;\n}\n\ntype UserRole = 'USER' | 'ADMIN';\n\ninterface CreateUserInput {\n  email: string;\n  password: string;\n}\n

Enums: PascalCase, members UPPER_SNAKE_CASE

enum UserRole {\n  USER = 'USER',\n  ADMIN = 'ADMIN',\n  SUPER_ADMIN = 'SUPER_ADMIN'\n}\n

"},{"location":"v2/development/code-style/#react-components","title":"React Components","text":"

Components: PascalCase

export function UserCard({ user }: { user: User }) {\n  return <div>{user.name}</div>;\n}\n

Props interfaces: ComponentNameProps

interface UserCardProps {\n  user: User;\n  onEdit?: (user: User) => void;\n}\n\nexport function UserCard({ user, onEdit }: UserCardProps) {\n  return <div>{user.name}</div>;\n}\n

Event handlers: handle[Event] or on[Event]

function UserForm() {\n  const handleSubmit = () => {};\n  const onEmailChange = (email: string) => {};\n\n  return <form onSubmit={handleSubmit}>...</form>;\n}\n

"},{"location":"v2/development/code-style/#database-models","title":"Database Models","text":"

Prisma models: PascalCase (singular)

model User {\n  id    Int    @id @default(autoincrement())\n  email String @unique\n}\n\nmodel Campaign {\n  id    Int    @id @default(autoincrement())\n  title String\n}\n

Table names: snake_case (plural)

model User {\n  @@map(\"users\")\n}\n\nmodel Campaign {\n  @@map(\"campaigns\")\n}\n

Fields: camelCase in schema, snake_case in database

model User {\n  createdAt DateTime @default(now()) @map(\"created_at\")\n  updatedAt DateTime @updatedAt @map(\"updated_at\")\n}\n

"},{"location":"v2/development/code-style/#file-organization","title":"File Organization","text":""},{"location":"v2/development/code-style/#module-structure","title":"Module Structure","text":"
src/modules/auth/\n\u251c\u2500\u2500 auth.service.ts        # Business logic\n\u251c\u2500\u2500 auth.routes.ts         # Express routes\n\u251c\u2500\u2500 auth.schemas.ts        # Zod validation schemas\n\u2514\u2500\u2500 auth.service.test.ts   # Tests\n
"},{"location":"v2/development/code-style/#import-order","title":"Import Order","text":"
  1. External libraries
  2. Internal modules (absolute imports)
  3. Relative imports
  4. Types
  5. Styles (frontend)
// 1. External libraries\nimport express from 'express';\nimport { z } from 'zod';\n\n// 2. Internal modules\nimport { authenticate } from '@/middleware/auth';\nimport { UserService } from '@/modules/users/user.service';\n\n// 3. Relative imports\nimport { AuthService } from './auth.service';\nimport { loginSchema } from './auth.schemas';\n\n// 4. Types\nimport type { Request, Response } from 'express';\nimport type { User } from '@prisma/client';\n\n// 5. Styles (frontend only)\nimport './auth.css';\n
"},{"location":"v2/development/code-style/#export-patterns","title":"Export Patterns","text":"

Named exports (preferred)

// auth.service.ts\nexport class AuthService {\n  async login() {}\n}\n\n// usage\nimport { AuthService } from './auth.service';\n

Default exports (React components)

// UserCard.tsx\nexport default function UserCard() {\n  return <div>...</div>;\n}\n\n// usage\nimport UserCard from './UserCard';\n

Re-exports (index files)

// modules/auth/index.ts\nexport { AuthService } from './auth.service';\nexport { authRoutes } from './auth.routes';\nexport * from './auth.schemas';\n

"},{"location":"v2/development/code-style/#code-patterns","title":"Code Patterns","text":""},{"location":"v2/development/code-style/#asyncawait","title":"Async/Await","text":"

Always use async/await (not callbacks or .then()):

Good:

async function getUser(id: number) {\n  const user = await prisma.user.findUnique({ where: { id } });\n  return user;\n}\n

Bad:

function getUser(id: number) {\n  return prisma.user.findUnique({ where: { id } }).then(user => {\n    return user;\n  });\n}\n

"},{"location":"v2/development/code-style/#error-handling","title":"Error Handling","text":"

Use try/catch for error handling:

Good:

async function createUser(data: CreateUserInput) {\n  try {\n    const user = await prisma.user.create({ data });\n    return user;\n  } catch (error) {\n    logger.error('Failed to create user', error);\n    throw new Error('User creation failed');\n  }\n}\n

Bad:

async function createUser(data: CreateUserInput) {\n  const user = await prisma.user.create({ data }); // Unhandled error\n  return user;\n}\n

"},{"location":"v2/development/code-style/#optional-chaining","title":"Optional Chaining","text":"

Use optional chaining for nullable values:

Good:

const email = user?.email;\nconst city = user?.address?.city;\n

Bad:

const email = user && user.email;\nconst city = user && user.address && user.address.city;\n

"},{"location":"v2/development/code-style/#nullish-coalescing","title":"Nullish Coalescing","text":"

Use ?? for default values (not ||):

Good:

const limit = query.limit ?? 50;\nconst name = user.name ?? 'Unknown';\n

Bad:

const limit = query.limit || 50; // Fails for 0\nconst name = user.name || 'Unknown'; // Fails for ''\n

"},{"location":"v2/development/code-style/#array-methods","title":"Array Methods","text":"

Prefer functional array methods:

Good:

const activeUsers = users.filter(u => u.isActive);\nconst emails = users.map(u => u.email);\nconst total = amounts.reduce((sum, amt) => sum + amt, 0);\n

Bad:

const activeUsers = [];\nfor (let i = 0; i < users.length; i++) {\n  if (users[i].isActive) {\n    activeUsers.push(users[i]);\n  }\n}\n

"},{"location":"v2/development/code-style/#object-destructuring","title":"Object Destructuring","text":"

Use destructuring for object properties:

Good:

const { email, name, role } = user;\nconst { limit = 50, page = 1 } = query;\n

Bad:

const email = user.email;\nconst name = user.name;\nconst role = user.role;\n

"},{"location":"v2/development/code-style/#template-literals","title":"Template Literals","text":"

Use template literals for string interpolation:

Good:

const message = `Hello, ${user.name}!`;\nconst url = `/api/users/${userId}`;\n

Bad:

const message = 'Hello, ' + user.name + '!';\nconst url = '/api/users/' + userId;\n

"},{"location":"v2/development/code-style/#comments-and-documentation","title":"Comments and Documentation","text":""},{"location":"v2/development/code-style/#jsdoc-for-functions","title":"JSDoc for Functions","text":"

Document public functions with JSDoc:

/**\n * Creates a new user with the given email and password.\n *\n * @param email - User's email address\n * @param password - User's password (will be hashed)\n * @returns Created user object\n * @throws {Error} If user already exists\n */\nasync function createUser(email: string, password: string): Promise<User> {\n  // ...\n}\n
"},{"location":"v2/development/code-style/#inline-comments","title":"Inline Comments","text":"

Use inline comments for complex logic:

// Calculate pagination offset\nconst offset = (page - 1) * limit;\n\n// Hash password with 10 salt rounds\nconst hashedPassword = await bcrypt.hash(password, 10);\n\n// Point-in-polygon ray-casting algorithm\nlet inside = false;\nfor (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {\n  // ... complex logic\n}\n
"},{"location":"v2/development/code-style/#avoid-obvious-comments","title":"Avoid Obvious Comments","text":"

Don't comment obvious code:

Good:

const isValid = email.includes('@');\n

Bad:

// Check if email is valid\nconst isValid = email.includes('@');\n

"},{"location":"v2/development/code-style/#todo-comments","title":"TODO Comments","text":"

Use TODO for future work:

// TODO: Add pagination support\nasync function getUsers() {\n  return prisma.user.findMany();\n}\n\n// FIXME: This doesn't handle edge case when user is null\nconst userName = user.name;\n
"},{"location":"v2/development/code-style/#git-commit-messages","title":"Git Commit Messages","text":""},{"location":"v2/development/code-style/#conventional-commits","title":"Conventional Commits","text":"

Use conventional commit format:

<type>(<scope>): <subject>\n\n<body>\n\n<footer>\n

Types: - feat: New feature - fix: Bug fix - docs: Documentation - style: Formatting - refactor: Code restructuring - test: Adding tests - chore: Maintenance

Examples:

feat(auth): add JWT refresh token rotation\nfix(map): correct point-in-polygon calculation\ndocs(api): update authentication guide\nrefactor(users): extract service layer\ntest(campaigns): add unit tests for CRUD operations\n

With scope and body:

git commit -m \"feat(campaigns): add email sending\n\nImplements BullMQ queue for async email delivery.\nAdds retry logic and error handling.\n\nCloses #123\"\n

"},{"location":"v2/development/code-style/#co-authoring-with-claude","title":"Co-Authoring with Claude","text":"

When Claude assists with code:

git commit -m \"$(cat <<'EOF'\nfeat(auth): add JWT refresh token rotation\n\nImplemented atomic refresh token rotation to prevent\nrace conditions during concurrent refresh requests.\n\nCo-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>\nEOF\n)\"\n
"},{"location":"v2/development/code-style/#react-patterns","title":"React Patterns","text":""},{"location":"v2/development/code-style/#functional-components","title":"Functional Components","text":"

Always use functional components (not class components):

Good:

export function UserCard({ user }: UserCardProps) {\n  return <div>{user.name}</div>;\n}\n

Bad:

export class UserCard extends React.Component<UserCardProps> {\n  render() {\n    return <div>{this.props.user.name}</div>;\n  }\n}\n

"},{"location":"v2/development/code-style/#hooks","title":"Hooks","text":"

Use hooks for state and side effects:

function UserList() {\n  const [users, setUsers] = useState<User[]>([]);\n  const [loading, setLoading] = useState(false);\n\n  useEffect(() => {\n    async function fetchUsers() {\n      setLoading(true);\n      const data = await api.get('/users');\n      setUsers(data);\n      setLoading(false);\n    }\n    fetchUsers();\n  }, []);\n\n  if (loading) return <div>Loading...</div>;\n\n  return <div>{users.map(u => <UserCard key={u.id} user={u} />)}</div>;\n}\n
"},{"location":"v2/development/code-style/#props-destructuring","title":"Props Destructuring","text":"

Destructure props in function signature:

Good:

function UserCard({ user, onEdit }: UserCardProps) {\n  return <div onClick={() => onEdit?.(user)}>{user.name}</div>;\n}\n

Bad:

function UserCard(props: UserCardProps) {\n  return <div onClick={() => props.onEdit?.(props.user)}>{props.user.name}</div>;\n}\n

"},{"location":"v2/development/code-style/#key-prop","title":"Key Prop","text":"

Always provide key for list items:

Good:

{users.map(user => (\n  <UserCard key={user.id} user={user} />\n))}\n

Bad:

{users.map((user, index) => (\n  <UserCard key={index} user={user} />\n))}\n

"},{"location":"v2/development/code-style/#editor-integration","title":"Editor Integration","text":""},{"location":"v2/development/code-style/#vscode-settings","title":"VSCode Settings","text":"

Create .vscode/settings.json:

{\n  \"editor.formatOnSave\": true,\n  \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n  \"editor.codeActionsOnSave\": {\n    \"source.fixAll.eslint\": true\n  },\n  \"typescript.tsdk\": \"node_modules/typescript/lib\",\n  \"typescript.enablePromptUseWorkspaceTsdk\": true,\n  \"[typescript]\": {\n    \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n  },\n  \"[typescriptreact]\": {\n    \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n  }\n}\n
"},{"location":"v2/development/code-style/#pre-commit-hook","title":"Pre-commit Hook","text":"

Install husky for pre-commit checks:

npm install --save-dev husky lint-staged\nnpx husky install\n

package.json:

{\n  \"lint-staged\": {\n    \"*.{ts,tsx}\": [\n      \"eslint --fix\",\n      \"prettier --write\"\n    ]\n  },\n  \"scripts\": {\n    \"prepare\": \"husky install\"\n  }\n}\n

.husky/pre-commit:

#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\nnpx lint-staged\n

"},{"location":"v2/development/code-style/#quick-reference","title":"Quick Reference","text":""},{"location":"v2/development/code-style/#run-linting","title":"Run Linting","text":"
# Lint\nnpm run lint\n\n# Auto-fix\nnpm run lint:fix\n\n# Format\nnpm run format\n\n# Type-check\nnpm run type-check\n
"},{"location":"v2/development/code-style/#common-fixes","title":"Common Fixes","text":"
# Fix all auto-fixable issues\nnpm run lint:fix && npm run format\n\n# Type-check both projects\ncd api && npm run type-check && cd ../admin && npm run type-check\n
"},{"location":"v2/development/code-style/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/development/code-style/#summary","title":"Summary","text":"

You now know: - \u2705 TypeScript configuration (strict mode) - \u2705 ESLint rules and plugins - \u2705 Prettier configuration - \u2705 Naming conventions (files, variables, types) - \u2705 File organization patterns - \u2705 Code patterns (async/await, error handling) - \u2705 Comment and documentation standards - \u2705 Git commit message format - \u2705 React patterns and best practices - \u2705 Editor integration (VSCode, pre-commit hooks)

Quick Start:

# Auto-fix and format\nnpm run lint:fix && npm run format\n\n# Check types\nnpm run type-check\n\n# Pre-commit\nnpm run lint:fix && npm run format && npm run type-check\n

"},{"location":"v2/development/debugging/","title":"Debugging Guide","text":"

Comprehensive guide to debugging Changemaker Lite V2 applications, covering API, frontend, database, and Docker debugging techniques.

"},{"location":"v2/development/debugging/#overview","title":"Overview","text":"

Effective debugging requires: - Understanding the tools (VSCode, Chrome DevTools, logs) - Systematic approach (reproduce, isolate, fix, verify) - Knowledge of common issues

This guide covers debugging strategies for all parts of V2.

"},{"location":"v2/development/debugging/#api-debugging","title":"API Debugging","text":""},{"location":"v2/development/debugging/#vscode-debugging","title":"VSCode Debugging","text":""},{"location":"v2/development/debugging/#launch-configuration","title":"Launch Configuration","text":"

Create .vscode/launch.json:

{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"name\": \"Debug API\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"runtimeExecutable\": \"npm\",\n      \"runtimeArgs\": [\"run\", \"dev\"],\n      \"cwd\": \"${workspaceFolder}/api\",\n      \"console\": \"integratedTerminal\",\n      \"skipFiles\": [\"<node_internals>/**\"],\n      \"envFile\": \"${workspaceFolder}/.env\",\n      \"sourceMaps\": true,\n      \"restart\": true,\n      \"protocol\": \"inspector\"\n    },\n    {\n      \"name\": \"Debug Media API\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"runtimeExecutable\": \"npm\",\n      \"runtimeArgs\": [\"run\", \"dev:media\"],\n      \"cwd\": \"${workspaceFolder}/api\",\n      \"console\": \"integratedTerminal\",\n      \"skipFiles\": [\"<node_internals>/**\"],\n      \"envFile\": \"${workspaceFolder}/.env\"\n    },\n    {\n      \"name\": \"Attach to API (Docker)\",\n      \"type\": \"node\",\n      \"request\": \"attach\",\n      \"port\": 9229,\n      \"address\": \"localhost\",\n      \"restart\": true,\n      \"sourceMaps\": true,\n      \"localRoot\": \"${workspaceFolder}/api\",\n      \"remoteRoot\": \"/app\",\n      \"skipFiles\": [\"<node_internals>/**\"]\n    }\n  ]\n}\n
"},{"location":"v2/development/debugging/#start-debugging","title":"Start Debugging","text":"
  1. Open VSCode
  2. Open Run and Debug panel (Cmd+Shift+D / Ctrl+Shift+D)
  3. Select \"Debug API\" configuration
  4. Press F5 to start debugging
  5. API starts with debugger attached
"},{"location":"v2/development/debugging/#set-breakpoints","title":"Set Breakpoints","text":"

Click line number gutter to set breakpoint:

// api/src/modules/auth/auth.service.ts\nasync login(email: string, password: string) {\n  const user = await this.prisma.user.findUnique({ // \u2190 Click here\n    where: { email }\n  });\n\n  if (!user) {\n    throw new Error('User not found'); // \u2190 Or here\n  }\n\n  // Breakpoint pauses execution\n  const isValid = await bcrypt.compare(password, user.password);\n\n  return { user, tokens: this.generateTokens(user) };\n}\n
"},{"location":"v2/development/debugging/#debug-features","title":"Debug Features","text":"

Step Controls: - F10: Step over (next line) - F11: Step into (enter function) - Shift+F11: Step out (exit function) - F5: Continue (run to next breakpoint)

Inspect Variables: - Hover over variable to see value - Use \"Variables\" panel to see all local variables - Use \"Watch\" panel to monitor specific expressions

Debug Console: - Evaluate expressions while paused - Call functions with current scope

// In debug console (while paused)\n> user.email\n'john@example.com'\n\n> bcrypt.compare('test', user.password)\nPromise { <pending> }\n\n> await bcrypt.compare('test', user.password)\nfalse\n

Call Stack: - See function call hierarchy - Click stack frame to jump to code - Useful for understanding execution flow

"},{"location":"v2/development/debugging/#logging-winston","title":"Logging (Winston)","text":""},{"location":"v2/development/debugging/#using-logger","title":"Using Logger","text":"
import { logger } from '../../utils/logger';\n\n// Info level\nlogger.info('User logged in', { userId: user.id, email: user.email });\n\n// Error level\nlogger.error('Failed to create user', {\n  error: error.message,\n  stack: error.stack,\n  email\n});\n\n// Warn level\nlogger.warn('Deprecated endpoint accessed', { endpoint: req.path });\n\n// Debug level (only in development)\nlogger.debug('Processing request', {\n  method: req.method,\n  path: req.path,\n  query: req.query\n});\n
"},{"location":"v2/development/debugging/#log-output","title":"Log Output","text":"

Development (console):

[2026-02-13 10:30:45] INFO: User logged in {\"userId\":1,\"email\":\"john@example.com\"}\n[2026-02-13 10:30:46] ERROR: Failed to create user {\"error\":\"Email already exists\",\"email\":\"john@example.com\"}\n

Production (JSON):

{\"level\":\"info\",\"message\":\"User logged in\",\"userId\":1,\"email\":\"john@example.com\",\"timestamp\":\"2026-02-13T10:30:45.123Z\"}\n{\"level\":\"error\",\"message\":\"Failed to create user\",\"error\":\"Email already exists\",\"email\":\"john@example.com\",\"timestamp\":\"2026-02-13T10:30:46.456Z\"}\n

"},{"location":"v2/development/debugging/#log-levels","title":"Log Levels","text":"

Set log level via environment:

# .env\nLOG_LEVEL=debug  # dev: debug, info, warn, error\nLOG_LEVEL=info   # prod: info, warn, error\n
"},{"location":"v2/development/debugging/#database-query-logging","title":"Database Query Logging","text":""},{"location":"v2/development/debugging/#prisma-query-logging","title":"Prisma Query Logging","text":"

Enable in Prisma Client:

// api/src/config/prisma.ts\nconst prisma = new PrismaClient({\n  log: [\n    { emit: 'event', level: 'query' },\n    { emit: 'event', level: 'error' },\n    { emit: 'event', level: 'warn' }\n  ]\n});\n\nprisma.$on('query', (e) => {\n  logger.debug('Prisma query', {\n    query: e.query,\n    params: e.params,\n    duration: e.duration\n  });\n});\n\nprisma.$on('error', (e) => {\n  logger.error('Prisma error', { target: e.target, message: e.message });\n});\n

Output:

[2026-02-13 10:30:45] DEBUG: Prisma query {\n  \"query\": \"SELECT * FROM users WHERE id = $1\",\n  \"params\": \"[1]\",\n  \"duration\": 5\n}\n

"},{"location":"v2/development/debugging/#slow-query-logging","title":"Slow Query Logging","text":"

Log slow queries:

prisma.$on('query', (e) => {\n  if (e.duration > 100) { // > 100ms\n    logger.warn('Slow query detected', {\n      query: e.query,\n      duration: e.duration,\n      params: e.params\n    });\n  }\n});\n
"},{"location":"v2/development/debugging/#network-debugging","title":"Network Debugging","text":""},{"location":"v2/development/debugging/#request-logging","title":"Request Logging","text":"

Log all HTTP requests:

// api/src/middleware/logger.ts\nimport { Request, Response, NextFunction } from 'express';\nimport { logger } from '../utils/logger';\n\nexport function requestLogger(req: Request, res: Response, next: NextFunction) {\n  const start = Date.now();\n\n  res.on('finish', () => {\n    const duration = Date.now() - start;\n\n    logger.info('HTTP request', {\n      method: req.method,\n      path: req.path,\n      status: res.statusCode,\n      duration,\n      ip: req.ip,\n      userAgent: req.get('user-agent')\n    });\n\n    if (duration > 1000) {\n      logger.warn('Slow request', {\n        method: req.method,\n        path: req.path,\n        duration\n      });\n    }\n  });\n\n  next();\n}\n\n// In server.ts\napp.use(requestLogger);\n
"},{"location":"v2/development/debugging/#testing-with-curl","title":"Testing with curl","text":"
# GET request\ncurl http://localhost:4000/api/users\n\n# POST request with JSON\ncurl -X POST http://localhost:4000/api/auth/login \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\":\"admin@example.com\",\"password\":\"Admin123!\"}'\n\n# With authentication\ncurl http://localhost:4000/api/users \\\n  -H \"Authorization: Bearer <token>\"\n\n# Verbose output (see headers)\ncurl -v http://localhost:4000/api/users\n\n# Save response to file\ncurl http://localhost:4000/api/users > users.json\n
"},{"location":"v2/development/debugging/#testing-with-httpie","title":"Testing with HTTPie","text":"
# Install httpie\nbrew install httpie  # macOS\nsudo apt install httpie  # Linux\n\n# GET request\nhttp localhost:4000/api/users\n\n# POST request\nhttp POST localhost:4000/api/auth/login \\\n  email=admin@example.com \\\n  password=Admin123!\n\n# With authentication\nhttp localhost:4000/api/users \\\n  Authorization:\"Bearer <token>\"\n\n# Pretty JSON output\nhttp --pretty=all localhost:4000/api/users\n
"},{"location":"v2/development/debugging/#frontend-debugging","title":"Frontend Debugging","text":""},{"location":"v2/development/debugging/#chrome-devtools","title":"Chrome DevTools","text":""},{"location":"v2/development/debugging/#opening-devtools","title":"Opening DevTools","text":""},{"location":"v2/development/debugging/#console-tab","title":"Console Tab","text":"

View console logs and errors:

// admin/src/pages/UsersPage.tsx\nconsole.log('Users loaded', users);\nconsole.error('Failed to fetch users', error);\nconsole.warn('Deprecated API used');\nconsole.table(users); // Display as table\n\n// Conditional logging\nif (import.meta.env.DEV) {\n  console.log('Debug info', { users, loading });\n}\n

Output:

Users loaded [{ id: 1, email: 'john@example.com' }, ...]\n

"},{"location":"v2/development/debugging/#sources-tab","title":"Sources Tab","text":"

Debug JavaScript/TypeScript:

  1. Open Sources tab
  2. Find file in file tree (webpack://./src/)
  3. Click line number to set breakpoint
  4. Interact with UI to trigger breakpoint
  5. Use step controls (same as VSCode)

Conditional Breakpoints: - Right-click line number - Select \"Add conditional breakpoint\" - Enter condition: user.id === 1 - Pauses only when condition is true

"},{"location":"v2/development/debugging/#network-tab","title":"Network Tab","text":"

Debug API calls:

  1. Open Network tab
  2. Filter by \"Fetch/XHR\"
  3. Interact with UI
  4. Click request to see:
  5. Headers (request/response)
  6. Payload (request body)
  7. Preview (formatted response)
  8. Response (raw response)
  9. Timing (request duration)

Common Issues: - 404 Not Found: Check URL path - 401 Unauthorized: Check token/auth header - 500 Server Error: Check API logs - CORS Error: Check CORS_ORIGIN setting

"},{"location":"v2/development/debugging/#application-tab","title":"Application Tab","text":"

Inspect storage:

// View in console\nlocalStorage.getItem('auth-token');\nsessionStorage.getItem('cart');\n
"},{"location":"v2/development/debugging/#react-devtools","title":"React DevTools","text":""},{"location":"v2/development/debugging/#installation","title":"Installation","text":"

Install browser extension: - Chrome - Firefox

"},{"location":"v2/development/debugging/#components-tab","title":"Components Tab","text":"

Inspect React component tree:

  1. Open DevTools
  2. Go to \"Components\" tab
  3. Select component from tree
  4. View:
  5. Props
  6. State (hooks)
  7. Context
  8. Owner (parent component)

Edit Props/State: - Click value to edit - Change takes effect immediately - Useful for testing edge cases

"},{"location":"v2/development/debugging/#profiler-tab","title":"Profiler Tab","text":"

Profile component renders:

  1. Go to \"Profiler\" tab
  2. Click \"Record\"
  3. Interact with UI
  4. Click \"Stop\"
  5. See:
  6. Flame graph (render hierarchy)
  7. Ranked chart (slowest components)
  8. Component details (render duration)

Identify Performance Issues: - Components rendering too often - Slow component renders - Unnecessary re-renders

"},{"location":"v2/development/debugging/#zustand-devtools","title":"Zustand DevTools","text":""},{"location":"v2/development/debugging/#enable-redux-devtools","title":"Enable Redux DevTools","text":"

Already configured in stores:

// admin/src/stores/auth.store.ts\nimport { create } from 'zustand';\nimport { devtools } from 'zustand/middleware';\n\nexport const useAuthStore = create<AuthState>()(\n  devtools(\n    (set, get) => ({\n      user: null,\n      isAuthenticated: false,\n      setUser: (user) => set({ user, isAuthenticated: !!user }),\n      logout: () => set({ user: null, isAuthenticated: false })\n    }),\n    { name: 'AuthStore' } // Name in DevTools\n  )\n);\n
"},{"location":"v2/development/debugging/#using-redux-devtools","title":"Using Redux DevTools","text":"
  1. Install Redux DevTools extension
  2. Open DevTools
  3. Go to \"Redux\" tab
  4. Select store from dropdown (AuthStore, CanvassStore)
  5. View:
  6. State tree
  7. Action history
  8. State diff

Features: - Time-travel debugging (jump to previous state) - Action replay - State export/import

"},{"location":"v2/development/debugging/#vscode-debugging-frontend","title":"VSCode Debugging (Frontend)","text":""},{"location":"v2/development/debugging/#launch-configuration_1","title":"Launch Configuration","text":"
{\n  \"name\": \"Debug Admin (Chrome)\",\n  \"type\": \"chrome\",\n  \"request\": \"launch\",\n  \"url\": \"http://localhost:3000\",\n  \"webRoot\": \"${workspaceFolder}/admin/src\",\n  \"sourceMapPathOverrides\": {\n    \"webpack:///./*\": \"${webRoot}/*\",\n    \"webpack:///src/*\": \"${webRoot}/*\",\n    \"webpack:///*\": \"*\"\n  },\n  \"userDataDir\": false\n}\n

Start Debugging: 1. Start Admin dev server: npm run dev 2. Select \"Debug Admin (Chrome)\" in VSCode 3. Press F5 4. Chrome opens with debugger attached 5. Set breakpoints in VSCode 6. Breakpoints hit when code executes

"},{"location":"v2/development/debugging/#database-debugging","title":"Database Debugging","text":""},{"location":"v2/development/debugging/#prisma-studio","title":"Prisma Studio","text":"

Visual database browser:

# Start Prisma Studio\ncd api\nnpx prisma studio\n

Features: - Browse all tables - Filter and sort data - Edit records directly - Create new records - Delete records

Use Cases: - Inspect database state - Manual data fixes - Verify migrations - Test queries

"},{"location":"v2/development/debugging/#postgresql-shell","title":"PostgreSQL Shell","text":"

Direct database access:

# Connect to database\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db\n\n# List tables\n\\dt\n\n# Describe table\n\\d users\n\n# Run query\nSELECT * FROM users WHERE role = 'SUPER_ADMIN';\n\n# Count records\nSELECT COUNT(*) FROM campaigns;\n\n# Exit\n\\q\n

Common Queries:

-- Find user by email\nSELECT * FROM users WHERE email = 'admin@example.com';\n\n-- Count users by role\nSELECT role, COUNT(*) FROM users GROUP BY role;\n\n-- Recent campaigns\nSELECT * FROM campaigns ORDER BY created_at DESC LIMIT 10;\n\n-- Users without name\nSELECT * FROM users WHERE name IS NULL;\n\n-- Delete test data\nDELETE FROM users WHERE email LIKE '%test%';\n
"},{"location":"v2/development/debugging/#query-analysis","title":"Query Analysis","text":""},{"location":"v2/development/debugging/#explain-query-plan","title":"Explain Query Plan","text":"
EXPLAIN ANALYZE\nSELECT * FROM users WHERE email = 'admin@example.com';\n

Output:

Index Scan using users_email_key on users (cost=0.28..8.29 rows=1 width=...)\n  Index Cond: (email = 'admin@example.com'::text)\n  Planning Time: 0.123 ms\n  Execution Time: 0.045 ms\n

Identify Issues: - Sequential scans (slow on large tables) - Missing indexes - Expensive joins

"},{"location":"v2/development/debugging/#slow-query-log","title":"Slow Query Log","text":"

Enable slow query logging:

-- Set log threshold (100ms)\nALTER DATABASE changemaker_v2_db SET log_min_duration_statement = 100;\n\n-- View slow queries in logs\ndocker compose logs v2-postgres | grep \"duration:\"\n
"},{"location":"v2/development/debugging/#docker-debugging","title":"Docker Debugging","text":""},{"location":"v2/development/debugging/#container-logs","title":"Container Logs","text":"

View container output:

# All services\ndocker compose logs -f\n\n# Specific service\ndocker compose logs -f api\n\n# Last 100 lines\ndocker compose logs --tail=100 api\n\n# With timestamps\ndocker compose logs -t -f api\n\n# Since specific time\ndocker compose logs --since 2024-01-01T10:00:00 api\n
"},{"location":"v2/development/debugging/#execute-commands-in-container","title":"Execute Commands in Container","text":"
# Shell access\ndocker compose exec api sh\n\n# Run command\ndocker compose exec api npm run type-check\n\n# Run as specific user\ndocker compose exec -u root api sh\n\n# Non-interactive command\ndocker compose exec -T api npm run lint\n
"},{"location":"v2/development/debugging/#inspect-container","title":"Inspect Container","text":"
# Container details\ndocker inspect api\n\n# Environment variables\ndocker inspect api | grep -A 20 \"Env\"\n\n# Mounts\ndocker inspect api | grep -A 50 \"Mounts\"\n\n# Network settings\ndocker inspect api | grep -A 20 \"Networks\"\n\n# Resource limits\ndocker inspect api | grep -A 10 \"Memory\"\n
"},{"location":"v2/development/debugging/#container-stats","title":"Container Stats","text":"
# Real-time stats\ndocker stats\n\n# Specific container\ndocker stats api\n\n# Format output\ndocker stats --format \"table {{.Container}}\\t{{.CPUPerc}}\\t{{.MemUsage}}\"\n
"},{"location":"v2/development/debugging/#network-debugging_1","title":"Network Debugging","text":"
# Test connectivity between containers\ndocker compose exec api ping v2-postgres\ndocker compose exec api ping redis\n\n# Check listening ports\ndocker compose exec api netstat -tuln\n\n# Test HTTP endpoint from inside container\ndocker compose exec api wget -O- http://localhost:4000/health\n\n# DNS lookup\ndocker compose exec api nslookup v2-postgres\n
"},{"location":"v2/development/debugging/#common-issues","title":"Common Issues","text":""},{"location":"v2/development/debugging/#401-unauthorized","title":"401 Unauthorized","text":"

Symptoms: API returns 401 for authenticated requests.

Causes: 1. Token expired 2. Invalid token 3. Missing Authorization header 4. Token format incorrect

Debug:

# Check token in browser DevTools\nlocalStorage.getItem('auth-token')\n\n# Test token with curl\ncurl http://localhost:4000/api/users \\\n  -H \"Authorization: Bearer <token>\" \\\n  -v\n\n# Decode JWT (jwt.io)\n# Check expiration (exp claim)\n

Fix: - Refresh token - Re-login - Check token format (Bearer prefix)

"},{"location":"v2/development/debugging/#500-internal-server-error","title":"500 Internal Server Error","text":"

Symptoms: API returns 500 error.

Causes: 1. Unhandled exception 2. Database error 3. External service failure

Debug:

# Check API logs\ndocker compose logs -f api\n\n# Look for error stack trace\ndocker compose logs api | grep -A 20 \"Error:\"\n\n# Check database connection\ndocker compose exec api npx prisma db execute --stdin <<< \"SELECT 1\"\n

Fix: - Check error message in logs - Verify database is running - Check external service (Redis, SMTP, etc.)

"},{"location":"v2/development/debugging/#cors-errors","title":"CORS Errors","text":"

Symptoms: Browser blocks request with CORS error.

Causes: 1. Incorrect CORS_ORIGIN setting 2. Missing CORS headers 3. Preflight OPTIONS request fails

Debug:

# Check CORS_ORIGIN in .env\ngrep CORS_ORIGIN .env\n\n# Test with curl (bypasses CORS)\ncurl http://localhost:4000/api/users\n\n# Check preflight request\ncurl -X OPTIONS http://localhost:4000/api/users \\\n  -H \"Origin: http://localhost:3000\" \\\n  -H \"Access-Control-Request-Method: GET\" \\\n  -v\n

Fix: - Set CORS_ORIGIN=http://localhost:3000 in .env - Restart API: docker compose restart api

"},{"location":"v2/development/debugging/#database-connection-errors","title":"Database Connection Errors","text":"

Symptoms: API fails to connect to database.

Causes: 1. PostgreSQL not running 2. Incorrect DATABASE_URL 3. Network issue

Debug:

# Check PostgreSQL is running\ndocker compose ps v2-postgres\n\n# Check DATABASE_URL\ndocker compose exec api sh -c 'echo $DATABASE_URL'\n\n# Test connection\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"SELECT 1\"\n\n# Check logs\ndocker compose logs v2-postgres\n

Fix: - Start PostgreSQL: docker compose up -d v2-postgres - Verify DATABASE_URL matches docker-compose.yml - Check password in .env

"},{"location":"v2/development/debugging/#redis-connection-errors","title":"Redis Connection Errors","text":"

Symptoms: API fails to connect to Redis.

Causes: 1. Redis not running 2. Incorrect REDIS_URL 3. Missing REDIS_PASSWORD

Debug:

# Check Redis is running\ndocker compose ps redis\n\n# Test connection\ndocker compose exec redis redis-cli -a your_password ping\n\n# Check Redis logs\ndocker compose logs redis\n

Fix: - Start Redis: docker compose up -d redis - Set REDIS_PASSWORD in .env - Update REDIS_URL with password

"},{"location":"v2/development/debugging/#hot-reload-not-working","title":"Hot Reload Not Working","text":"

Symptoms: Code changes don't trigger reload.

Causes: 1. Volume mount missing 2. File watcher not detecting changes 3. Build cache issue

Debug:

# Check volume mounts\ndocker inspect api | grep -A 20 \"Mounts\"\n\n# Test file sync\ndocker compose exec api ls -la /app/src\n\n# Check for .dockerignore blocking sync\ncat api/.dockerignore\n

Fix: - Verify volume mount in docker-compose.yml - Restart container: docker compose restart api - Clear cache: rm -rf api/dist && docker compose restart api

"},{"location":"v2/development/debugging/#debug-checklist","title":"Debug Checklist","text":""},{"location":"v2/development/debugging/#systematic-debugging-approach","title":"Systematic Debugging Approach","text":"
  1. Reproduce:
  2. Can you consistently reproduce the issue?
  3. What are the exact steps?

  4. Isolate:

  5. Does it happen in all environments?
  6. Is it specific to one user/data/scenario?

  7. Gather Information:

  8. Check logs (API, frontend, database)
  9. Check network requests (DevTools)
  10. Check error messages

  11. Form Hypothesis:

  12. What do you think is causing it?
  13. What evidence supports this?

  14. Test Hypothesis:

  15. Set breakpoints
  16. Add logging
  17. Test specific scenario

  18. Fix:

  19. Make minimal change to fix issue
  20. Don't fix multiple issues at once

  21. Verify:

  22. Re-test original scenario
  23. Test related functionality
  24. Check for side effects

  25. Prevent:

  26. Add tests to catch regression
  27. Update documentation
  28. Share learnings with team
"},{"location":"v2/development/debugging/#performance-debugging","title":"Performance Debugging","text":""},{"location":"v2/development/debugging/#api-response-time","title":"API Response Time","text":"
// Measure endpoint performance\napp.get('/users', async (req, res) => {\n  const start = Date.now();\n\n  const users = await prisma.user.findMany();\n\n  const duration = Date.now() - start;\n  logger.info('Users endpoint', { duration, count: users.length });\n\n  res.json({ users });\n});\n
"},{"location":"v2/development/debugging/#database-query-performance","title":"Database Query Performance","text":"
// Log slow queries\nprisma.$on('query', (e) => {\n  if (e.duration > 100) {\n    logger.warn('Slow query', {\n      query: e.query,\n      duration: e.duration,\n      params: e.params\n    });\n  }\n});\n
"},{"location":"v2/development/debugging/#frontend-render-performance","title":"Frontend Render Performance","text":"
// Measure component render time\nfunction UserList() {\n  const renderStart = performance.now();\n\n  useEffect(() => {\n    const renderTime = performance.now() - renderStart;\n    if (renderTime > 16) { // > 1 frame (60fps)\n      console.warn('Slow render', { component: 'UserList', renderTime });\n    }\n  });\n\n  return <div>...</div>;\n}\n
"},{"location":"v2/development/debugging/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/development/debugging/#summary","title":"Summary","text":"

You now know: - \u2705 How to debug API with VSCode - \u2705 How to use Winston logging effectively - \u2705 How to debug frontend with Chrome DevTools - \u2705 How to use React DevTools and Zustand DevTools - \u2705 How to debug database with Prisma Studio and psql - \u2705 How to debug Docker containers - \u2705 Common issues and their solutions - \u2705 Systematic debugging approach - \u2705 Performance debugging techniques

Quick Start:

# API debugging\ncd api && npm run dev  # Start with debugger\n# Set breakpoints in VSCode, press F5\n\n# Frontend debugging\n# Open Chrome DevTools (F12)\n# Network tab for API calls, Console for logs\n\n# Database debugging\nnpx prisma studio  # Visual browser\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db\n\n# Logs\ndocker compose logs -f api admin\n

"},{"location":"v2/development/docker-workflow/","title":"Docker Development Workflow","text":"

Guide to developing Changemaker Lite V2 using Docker containers for consistent, reproducible development environments.

"},{"location":"v2/development/docker-workflow/#overview","title":"Overview","text":"

Docker-based development provides:

This guide covers Docker development workflows, from basic container operations to advanced debugging techniques.

"},{"location":"v2/development/docker-workflow/#docker-vs-local-development","title":"Docker vs Local Development","text":""},{"location":"v2/development/docker-workflow/#when-to-use-docker","title":"When to Use Docker","text":"

Advantages: - Consistent Node.js/PostgreSQL/Redis versions - No need to install services on host machine - Easy onboarding for new developers - Production-like environment - Volume mounts still support hot reload

Disadvantages: - Slightly slower hot reload (especially macOS/Windows) - More complex debugging setup - Volume mount performance overhead - Larger disk space usage

"},{"location":"v2/development/docker-workflow/#when-to-use-local-npm","title":"When to Use Local npm","text":"

Advantages: - Faster hot reload (native file system) - Direct access to Node.js processes - Simpler debugging (VSCode attach) - Better performance on macOS/Windows

Disadvantages: - Must install Node.js, PostgreSQL, Redis locally - Version inconsistencies between developers - Host system configuration required

"},{"location":"v2/development/docker-workflow/#hybrid-approach-recommended","title":"Hybrid Approach (Recommended)","text":"

Run databases in Docker, API/Admin locally:

# Docker: Databases only\ndocker compose up -d v2-postgres redis mailhog\n\n# Local: Development servers\ncd api && npm run dev\ncd admin && npm run dev\n

This combines benefits of both approaches.

"},{"location":"v2/development/docker-workflow/#starting-development-services","title":"Starting Development Services","text":""},{"location":"v2/development/docker-workflow/#full-docker-development","title":"Full Docker Development","text":"

Start all development services:

# Core services (API, Admin, Databases)\ndocker compose up -d api admin v2-postgres redis\n\n# Optional: MailHog for email testing\ndocker compose up -d mailhog\n\n# Optional: Media API\ndocker compose up -d media-api\n

Verify services started:

docker compose ps\n

Expected output:

NAME                  STATUS    PORTS\napi                   running   0.0.0.0:4000->4000/tcp\nadmin                 running   0.0.0.0:3000->3000/tcp\nv2-postgres           running   0.0.0.0:5433->5432/tcp\nredis                 running   0.0.0.0:6379->6379/tcp\nmailhog               running   0.0.0.0:1025->1025/tcp, 0.0.0.0:8025->8025/tcp\n

"},{"location":"v2/development/docker-workflow/#selective-service-start","title":"Selective Service Start","text":"

Start only what you need:

# Just databases (for local npm development)\ndocker compose up -d v2-postgres redis\n\n# Just API (admin running locally)\ndocker compose up -d api v2-postgres redis\n\n# Just Admin (API running locally)\ndocker compose up -d admin\n
"},{"location":"v2/development/docker-workflow/#start-with-monitoring-stack","title":"Start with Monitoring Stack","text":"

Enable monitoring services:

# Start with monitoring profile\ndocker compose --profile monitoring up -d\n\n# Or specific monitoring services\ndocker compose up -d prometheus grafana\n
"},{"location":"v2/development/docker-workflow/#watching-logs","title":"Watching Logs","text":""},{"location":"v2/development/docker-workflow/#view-service-logs","title":"View Service Logs","text":"

Real-time log streaming:

# All services\ndocker compose logs -f\n\n# Specific service\ndocker compose logs -f api\n\n# Multiple services\ndocker compose logs -f api admin\n\n# Last 100 lines, then follow\ndocker compose logs -f --tail=100 api\n

Log output example (API):

api  | Server running on port 4000\napi  | Database connected\napi  | Redis connected\napi  | BullMQ worker started\napi  | GET /api/users 200 45ms\n

Log output example (Admin):

admin  | VITE v5.x.x ready in 500 ms\nadmin  | \u279c  Local:   http://localhost:3000/\nadmin  | \u279c  Network: http://172.18.0.5:3000/\n

"},{"location":"v2/development/docker-workflow/#filter-logs","title":"Filter Logs","text":"

Use grep to filter log output:

# Show only errors\ndocker compose logs -f api | grep ERROR\n\n# Show only database queries\ndocker compose logs -f api | grep \"SELECT\\|INSERT\\|UPDATE\\|DELETE\"\n\n# Show only HTTP requests\ndocker compose logs -f api | grep \"GET\\|POST\\|PUT\\|DELETE\"\n
"},{"location":"v2/development/docker-workflow/#export-logs","title":"Export Logs","text":"

Save logs to file:

# All services\ndocker compose logs > logs.txt\n\n# Specific service with timestamps\ndocker compose logs -t api > api-logs.txt\n\n# Last 24 hours\ndocker compose logs --since 24h > recent-logs.txt\n
"},{"location":"v2/development/docker-workflow/#executing-commands-in-containers","title":"Executing Commands in Containers","text":""},{"location":"v2/development/docker-workflow/#using-docker-compose-exec","title":"Using docker compose exec","text":"

Run commands inside running containers:

# General syntax\ndocker compose exec <service> <command>\n\n# Examples:\ndocker compose exec api npm run type-check\ndocker compose exec admin npm run lint\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db\n
"},{"location":"v2/development/docker-workflow/#common-api-commands","title":"Common API Commands","text":"
# Type-check\ndocker compose exec api npm run type-check\n\n# Prisma migrate\ndocker compose exec api npx prisma migrate dev --name add_field\n\n# Prisma Studio\ndocker compose exec api npx prisma studio\n\n# Seed database\ndocker compose exec api npx prisma db seed\n\n# Drizzle push (Media API)\ndocker compose exec api npx drizzle-kit push\n\n# Node REPL\ndocker compose exec api node\n\n# Shell access\ndocker compose exec api sh\n
"},{"location":"v2/development/docker-workflow/#common-admin-commands","title":"Common Admin Commands","text":"
# Type-check\ndocker compose exec admin npm run type-check\n\n# Build\ndocker compose exec admin npm run build\n\n# Lint\ndocker compose exec admin npm run lint\n\n# Shell access\ndocker compose exec admin sh\n
"},{"location":"v2/development/docker-workflow/#database-commands","title":"Database Commands","text":"
# PostgreSQL shell\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db\n\n# Run SQL query\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"SELECT COUNT(*) FROM users;\"\n\n# Dump database\ndocker compose exec v2-postgres pg_dump -U changemaker_v2 changemaker_v2_db > backup.sql\n\n# Restore database\ncat backup.sql | docker compose exec -T v2-postgres psql -U changemaker_v2 changemaker_v2_db\n
"},{"location":"v2/development/docker-workflow/#redis-commands","title":"Redis Commands","text":"
# Redis CLI\ndocker compose exec redis redis-cli -a your_redis_password\n\n# Ping\ndocker compose exec redis redis-cli -a your_redis_password ping\n\n# Get all keys\ndocker compose exec redis redis-cli -a your_redis_password KEYS '*'\n\n# Monitor commands\ndocker compose exec redis redis-cli -a your_redis_password MONITOR\n
"},{"location":"v2/development/docker-workflow/#hot-reload-in-containers","title":"Hot Reload in Containers","text":""},{"location":"v2/development/docker-workflow/#how-volume-mounts-enable-hot-reload","title":"How Volume Mounts Enable Hot Reload","text":"

Docker Compose volume mounts sync code between host and container:

# docker-compose.yml\napi:\n  volumes:\n    - ./api:/app                # Syncs code changes\n    - /app/node_modules         # Preserves container's node_modules\n    - /app/dist                 # Preserves build output\n

When you edit a file on host: 1. File change detected by host file system 2. Change synced to container via volume mount 3. tsx watch (API) or Vite (Admin) detects change 4. Service restarts (API) or HMR updates (Admin)

"},{"location":"v2/development/docker-workflow/#api-hot-reload","title":"API Hot Reload","text":"

API uses tsx watch for auto-restart:

# Start API in Docker\ndocker compose up -d api\n\n# Watch logs\ndocker compose logs -f api\n\n# Edit file: api/src/modules/auth/auth.service.ts\n# Logs show:\n# api  | File changed: src/modules/auth/auth.service.ts\n# api  | Restarting server...\n# api  | Server running on port 4000\n

What triggers reload: - .ts file changes in src/ - Schema changes (after Prisma migrate)

What does NOT trigger reload: - .env changes (restart container manually) - package.json changes (rebuild container)

"},{"location":"v2/development/docker-workflow/#admin-hot-reload-vite-hmr","title":"Admin Hot Reload (Vite HMR)","text":"

Admin uses Vite Hot Module Replacement:

# Start Admin in Docker\ndocker compose up -d admin\n\n# Watch logs\ndocker compose logs -f admin\n\n# Edit file: admin/src/pages/UsersPage.tsx\n# Logs show:\n# admin  | 10:30:45 AM [vite] hmr update /src/pages/UsersPage.tsx\n# Browser updates WITHOUT full reload\n

HMR behavior: - Component changes: Updates component only - CSS changes: Updates styles instantly - Store changes: May require full reload

"},{"location":"v2/development/docker-workflow/#performance-considerations","title":"Performance Considerations","text":"

Linux: Volume mounts are native, excellent performance.

macOS/Windows: Volume mounts use virtualization layer, slower performance.

Optimization for macOS/Windows:

  1. Use delegated volume mounts (docker-compose.yml):
api:\n  volumes:\n    - ./api:/app:delegated  # Slightly better performance\n
  1. Reduce watched files (.dockerignore):
node_modules\ndist\ncoverage\n.git\n*.log\n
  1. Use local development for intensive work:
# Stop Docker services\ndocker compose stop api admin\n\n# Run locally\ncd api && npm run dev\ncd admin && npm run dev\n
"},{"location":"v2/development/docker-workflow/#database-operations","title":"Database Operations","text":""},{"location":"v2/development/docker-workflow/#running-migrations-in-docker","title":"Running Migrations in Docker","text":"
# Create migration\ndocker compose exec api npx prisma migrate dev --name add_user_field\n\n# Apply migrations (production)\ndocker compose exec api npx prisma migrate deploy\n\n# Check migration status\ndocker compose exec api npx prisma migrate status\n
"},{"location":"v2/development/docker-workflow/#seeding-database","title":"Seeding Database","text":"
# Run seed script\ndocker compose exec api npx prisma db seed\n\n# Or run custom script\ndocker compose exec api npx tsx prisma/custom-seed.ts\n
"},{"location":"v2/development/docker-workflow/#resetting-database","title":"Resetting Database","text":"

WARNING: Deletes all data!

# Reset and re-seed\ndocker compose exec api npx prisma migrate reset\n\n# Confirm when prompted:\n# \u26a0\ufe0f  All data will be lost. Continue? [y/N]: y\n
"},{"location":"v2/development/docker-workflow/#prisma-studio-in-docker","title":"Prisma Studio in Docker","text":"
# Start Prisma Studio\ndocker compose exec api npx prisma studio\n\n# Access at http://localhost:5555\n

Note: Port forwarding must be configured (already set in docker-compose.yml).

"},{"location":"v2/development/docker-workflow/#manual-database-access","title":"Manual Database Access","text":"
# Open PostgreSQL shell\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db\n\n# Run queries\nchangemaker_v2_db=# SELECT * FROM users;\nchangemaker_v2_db=# \\dt  -- List tables\nchangemaker_v2_db=# \\q   -- Exit\n
"},{"location":"v2/development/docker-workflow/#rebuilding-containers","title":"Rebuilding Containers","text":""},{"location":"v2/development/docker-workflow/#when-to-rebuild","title":"When to Rebuild","text":"

Rebuild containers when: - package.json dependencies change - Dockerfile changes - Base image needs update - Container is in corrupted state

"},{"location":"v2/development/docker-workflow/#rebuild-commands","title":"Rebuild Commands","text":"
# Rebuild all services\ndocker compose build\n\n# Rebuild specific service\ndocker compose build api\n\n# Rebuild without cache (clean build)\ndocker compose build --no-cache api\n\n# Rebuild and restart\ndocker compose up -d --build api\n
"},{"location":"v2/development/docker-workflow/#full-rebuild-workflow","title":"Full Rebuild Workflow","text":"
# 1. Stop services\ndocker compose down\n\n# 2. Rebuild (no cache)\ndocker compose build --no-cache\n\n# 3. Start services\ndocker compose up -d\n\n# 4. Verify\ndocker compose ps\ndocker compose logs -f api admin\n
"},{"location":"v2/development/docker-workflow/#after-package-changes","title":"After Package Changes","text":"

When package.json changes (new dependencies):

# Option 1: Rebuild container\ndocker compose build --no-cache api\ndocker compose restart api\n\n# Option 2: Install in running container\ndocker compose exec api npm install\ndocker compose restart api\n\n# Option 3: Remove and recreate\ndocker compose rm -sf api\ndocker compose up -d api\n
"},{"location":"v2/development/docker-workflow/#cleaning-up","title":"Cleaning Up","text":""},{"location":"v2/development/docker-workflow/#stop-services","title":"Stop Services","text":"
# Stop all services\ndocker compose stop\n\n# Stop specific service\ndocker compose stop api\n\n# Stop and remove containers\ndocker compose down\n
"},{"location":"v2/development/docker-workflow/#remove-containers","title":"Remove Containers","text":"
# Remove containers (keeps volumes)\ndocker compose down\n\n# Remove containers and volumes (DELETES DATA)\ndocker compose down -v\n\n# Remove containers, volumes, and images\ndocker compose down -v --rmi all\n
"},{"location":"v2/development/docker-workflow/#clean-docker-system","title":"Clean Docker System","text":"
# Remove stopped containers\ndocker container prune\n\n# Remove unused images\ndocker image prune\n\n# Remove unused volumes\ndocker volume prune\n\n# Remove everything (DANGEROUS)\ndocker system prune -a --volumes\n
"},{"location":"v2/development/docker-workflow/#clean-project-volumes","title":"Clean Project Volumes","text":"
# List project volumes\ndocker volume ls | grep changemaker\n\n# Remove specific volume\ndocker volume rm changemaker-lite_v2-postgres-data\n\n# Remove all project volumes (DELETES DATABASE)\ndocker compose down -v\n
"},{"location":"v2/development/docker-workflow/#reset-development-environment","title":"Reset Development Environment","text":"

Complete reset (deletes all data):

# 1. Stop and remove everything\ndocker compose down -v --rmi all\n\n# 2. Clean Docker system\ndocker system prune -a --volumes -f\n\n# 3. Rebuild from scratch\ndocker compose build --no-cache\n\n# 4. Start services\ndocker compose up -d\n\n# 5. Run migrations\ndocker compose exec api npx prisma migrate deploy\ndocker compose exec api npx prisma db seed\n
"},{"location":"v2/development/docker-workflow/#debugging-in-docker","title":"Debugging in Docker","text":""},{"location":"v2/development/docker-workflow/#attach-to-running-container","title":"Attach to Running Container","text":"
# Get shell in running container\ndocker compose exec api sh\n\n# Or bash (if available)\ndocker compose exec api bash\n\n# Inside container:\n# - Explore file system\n# - Run commands\n# - Check environment variables\n
"},{"location":"v2/development/docker-workflow/#inspect-container","title":"Inspect Container","text":"
# View container details\ndocker inspect api\n\n# View container environment variables\ndocker inspect api | grep -A 20 \"Env\"\n\n# View container mounts\ndocker inspect api | grep -A 50 \"Mounts\"\n
"},{"location":"v2/development/docker-workflow/#vscode-remote-containers","title":"VSCode Remote Containers","text":"

Install \"Remote - Containers\" extension, then:

  1. Open Command Palette (Cmd+Shift+P / Ctrl+Shift+P)
  2. Select \"Remote-Containers: Attach to Running Container\"
  3. Choose api or admin container
  4. VSCode opens new window attached to container
  5. Open /app folder in container
  6. Set breakpoints and debug normally
"},{"location":"v2/development/docker-workflow/#debug-logs","title":"Debug Logs","text":"

Enable verbose logging:

# API with debug logs\ndocker compose exec api npm run dev -- --inspect\n\n# Watch logs with timestamp\ndocker compose logs -f -t api\n\n# Filter errors only\ndocker compose logs -f api 2>&1 | grep -i error\n
"},{"location":"v2/development/docker-workflow/#network-debugging","title":"Network Debugging","text":"
# Test container connectivity\ndocker compose exec api ping v2-postgres\ndocker compose exec api ping redis\n\n# Check listening ports\ndocker compose exec api netstat -tuln\n\n# Test API from inside container\ndocker compose exec api wget -O- http://localhost:4000/health\n
"},{"location":"v2/development/docker-workflow/#performance-debugging","title":"Performance Debugging","text":"
# Container stats\ndocker stats\n\n# Specific service stats\ndocker stats api admin\n\n# Container resource limits\ndocker inspect api | grep -A 10 \"Memory\\|Cpu\"\n
"},{"location":"v2/development/docker-workflow/#advanced-workflows","title":"Advanced Workflows","text":""},{"location":"v2/development/docker-workflow/#multi-stage-development","title":"Multi-Stage Development","text":"

Run different service combinations:

# Frontend development (local Admin, Docker API)\ndocker compose up -d api v2-postgres redis\ncd admin && npm run dev\n\n# Backend development (local API, Docker Admin)\ndocker compose up -d admin v2-postgres redis\ncd api && npm run dev\n\n# Full-stack (everything in Docker)\ndocker compose up -d api admin v2-postgres redis\n
"},{"location":"v2/development/docker-workflow/#custom-docker-compose-files","title":"Custom Docker Compose Files","text":"

Create docker-compose.dev.yml for dev overrides:

# docker-compose.dev.yml\nservices:\n  api:\n    command: npm run dev -- --inspect=0.0.0.0:9229\n    ports:\n      - \"9229:9229\"  # Debug port\n    environment:\n      - LOG_LEVEL=debug\n

Usage:

# Use both files\ndocker compose -f docker-compose.yml -f docker-compose.dev.yml up -d\n\n# Or set COMPOSE_FILE env var\nexport COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml\ndocker compose up -d\n

"},{"location":"v2/development/docker-workflow/#docker-profiles-for-optional-services","title":"Docker Profiles for Optional Services","text":"

Start monitoring stack:

# With monitoring services\ndocker compose --profile monitoring up -d\n\n# Without monitoring (default)\ndocker compose up -d\n

Monitoring services: - Prometheus (port 9090) - Grafana (port 3001) - Alertmanager (port 9093) - cAdvisor (port 8080)

"},{"location":"v2/development/docker-workflow/#build-arguments","title":"Build Arguments","text":"

Pass build-time arguments:

# Build with Node.js version argument\ndocker compose build --build-arg NODE_VERSION=20.11.0 api\n\n# Set in docker-compose.yml\nservices:\n  api:\n    build:\n      context: ./api\n      args:\n        - NODE_VERSION=${NODE_VERSION:-20}\n
"},{"location":"v2/development/docker-workflow/#health-checks","title":"Health Checks","text":"

Check service health:

# View health status\ndocker compose ps\n\n# Inspect health check\ndocker inspect --format='{{json .State.Health}}' api | jq\n\n# Wait for healthy\ndocker compose up -d api\ndocker compose exec api sh -c 'while ! wget -q -O- http://localhost:4000/health; do sleep 1; done'\n
"},{"location":"v2/development/docker-workflow/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/development/docker-workflow/#container-exits-immediately","title":"Container Exits Immediately","text":"

Problem: Container starts then stops.

Solution:

# Check logs for errors\ndocker compose logs api\n\n# Common causes:\n# 1. Missing .env file\n# 2. Database connection failed\n# 3. Syntax error in code\n# 4. Port already in use\n\n# Start with interactive mode to see error\ndocker compose run --rm api npm run dev\n
"},{"location":"v2/development/docker-workflow/#volume-mount-not-working","title":"Volume Mount Not Working","text":"

Problem: Code changes don't appear in container.

Solution:

# Check volume mounts\ndocker inspect api | grep -A 20 \"Mounts\"\n\n# Verify volume path\ndocker compose exec api ls -la /app\n\n# Recreate container\ndocker compose rm -sf api\ndocker compose up -d api\n
"},{"location":"v2/development/docker-workflow/#permission-errors","title":"Permission Errors","text":"

Problem: Permission denied errors in container.

Solution:

# Check file ownership\ndocker compose exec api ls -la /app\n\n# Fix permissions on host\nsudo chown -R $(whoami):$(whoami) ./api\n\n# Or run container as current user (docker-compose.yml)\nservices:\n  api:\n    user: \"${UID}:${GID}\"\n
"},{"location":"v2/development/docker-workflow/#port-conflicts","title":"Port Conflicts","text":"

Problem: Port already in use.

Solution:

# Find process using port\nlsof -ti:4000 | xargs kill -9\n\n# Or change port in docker-compose.yml\nservices:\n  api:\n    ports:\n      - \"4002:4000\"  # Host:Container\n\n# Or use .env\nAPI_PORT=4002\n
"},{"location":"v2/development/docker-workflow/#database-connection-failed","title":"Database Connection Failed","text":"

Problem: API cannot connect to PostgreSQL.

Solution:

# Check database is running\ndocker compose ps v2-postgres\n\n# Check database logs\ndocker compose logs v2-postgres\n\n# Test connection\ndocker compose exec api sh -c 'wget -qO- http://v2-postgres:5432 || echo \"Not reachable\"'\n\n# Verify DATABASE_URL\ndocker compose exec api sh -c 'echo $DATABASE_URL'\n\n# Restart database\ndocker compose restart v2-postgres\n
"},{"location":"v2/development/docker-workflow/#out-of-disk-space","title":"Out of Disk Space","text":"

Problem: No space left on device.

Solution:

# Check Docker disk usage\ndocker system df\n\n# Remove unused images\ndocker image prune -a\n\n# Remove unused volumes\ndocker volume prune\n\n# Remove build cache\ndocker builder prune\n\n# Full cleanup\ndocker system prune -a --volumes\n
"},{"location":"v2/development/docker-workflow/#container-running-out-of-memory","title":"Container Running Out of Memory","text":"

Problem: Container crashes with OOM.

Solution:

# Check container stats\ndocker stats api\n\n# Increase Docker memory limit (Docker Desktop \u2192 Preferences \u2192 Resources)\n\n# Or set memory limit in docker-compose.yml\nservices:\n  api:\n    mem_limit: 2g\n    memswap_limit: 2g\n
"},{"location":"v2/development/docker-workflow/#slow-performance-on-macoswindows","title":"Slow Performance on macOS/Windows","text":"

Problem: Slow hot reload, high CPU usage.

Solution:

  1. Use delegated volume mounts:
services:\n  api:\n    volumes:\n      - ./api:/app:delegated\n
  1. Reduce file watching:
// vite.config.ts\nexport default {\n  server: {\n    watch: {\n      ignored: ['**/node_modules/**', '**/dist/**']\n    }\n  }\n}\n
  1. Switch to local development:
docker compose up -d v2-postgres redis\ncd api && npm run dev\ncd admin && npm run dev\n
"},{"location":"v2/development/docker-workflow/#best-practices","title":"Best Practices","text":""},{"location":"v2/development/docker-workflow/#development-workflow","title":"Development Workflow","text":"
  1. Start services in background:

    docker compose up -d api admin\n

  2. Watch logs in separate terminal:

    docker compose logs -f api admin\n

  3. Make code changes:

  4. Hot reload picks up changes automatically

  5. Type-check before commit:

    docker compose exec api npm run type-check\ndocker compose exec admin npm run type-check\n

  6. Stop services when done:

    docker compose stop\n

"},{"location":"v2/development/docker-workflow/#container-naming","title":"Container Naming","text":"

Use meaningful service names in docker-compose.yml:

services:\n  api:              # Not \"backend\" or \"server\"\n  admin:            # Not \"frontend\" or \"ui\"\n  v2-postgres:      # Not \"db\" (version-specific)\n  redis:            # Standard name\n
"},{"location":"v2/development/docker-workflow/#environment-variables","title":"Environment Variables","text":"
  1. Use .env file (not docker-compose.yml):

    # .env\nAPI_PORT=4000\nADMIN_PORT=3000\n

  2. Reference in docker-compose.yml:

    services:\n  api:\n    environment:\n      - API_PORT=${API_PORT}\n

  3. Don't commit .env (use .env.example).

"},{"location":"v2/development/docker-workflow/#volume-management","title":"Volume Management","text":"
  1. Named volumes for data:

    volumes:\n  v2-postgres-data:  # Persistent database\n

  2. Bind mounts for code:

    volumes:\n  - ./api:/app  # Live code sync\n

  3. Anonymous volumes for dependencies:

    volumes:\n  - /app/node_modules  # Isolate from host\n

"},{"location":"v2/development/docker-workflow/#log-management","title":"Log Management","text":"
  1. Use log rotation:

    services:\n  api:\n    logging:\n      driver: \"json-file\"\n      options:\n        max-size: \"10m\"\n        max-file: \"3\"\n

  2. Filter logs with grep:

    docker compose logs -f api | grep ERROR\n

  3. Export logs for analysis:

    docker compose logs > debug-logs.txt\n

"},{"location":"v2/development/docker-workflow/#quick-reference","title":"Quick Reference","text":""},{"location":"v2/development/docker-workflow/#essential-commands","title":"Essential Commands","text":"
# Start\ndocker compose up -d api admin\n\n# Stop\ndocker compose stop\n\n# Restart\ndocker compose restart api\n\n# Logs\ndocker compose logs -f api\n\n# Execute command\ndocker compose exec api npm run type-check\n\n# Shell access\ndocker compose exec api sh\n\n# Rebuild\ndocker compose build --no-cache api\n\n# Clean up\ndocker compose down -v\n
"},{"location":"v2/development/docker-workflow/#service-health-checks","title":"Service Health Checks","text":"
# Check status\ndocker compose ps\n\n# Test API\ncurl http://localhost:4000/health\n\n# Test Admin\ncurl http://localhost:3000\n\n# Test database\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"SELECT 1\"\n\n# Test Redis\ndocker compose exec redis redis-cli -a password ping\n
"},{"location":"v2/development/docker-workflow/#quick-reset","title":"Quick Reset","text":"
# Full reset (DELETES DATA)\ndocker compose down -v\ndocker compose build --no-cache\ndocker compose up -d\ndocker compose exec api npx prisma migrate deploy\ndocker compose exec api npx prisma db seed\n
"},{"location":"v2/development/docker-workflow/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/development/docker-workflow/#summary","title":"Summary","text":"

You now know: - \u2705 When to use Docker vs local development - \u2705 How to start and stop services - \u2705 How to watch and filter logs - \u2705 How to execute commands in containers - \u2705 How hot reload works with volume mounts - \u2705 How to perform database operations in Docker - \u2705 How to rebuild and clean up containers - \u2705 How to debug containerized services - \u2705 Advanced workflows and best practices

Quick Start:

docker compose up -d api admin v2-postgres redis\ndocker compose logs -f api admin\n# Make changes \u2192 Hot reload!\ndocker compose exec api npm run type-check\ndocker compose stop\n

"},{"location":"v2/development/git-workflow/","title":"Git Workflow","text":"

Git branching strategy, commit conventions, and version control best practices for Changemaker Lite V2.

"},{"location":"v2/development/git-workflow/#overview","title":"Overview","text":"

Changemaker Lite V2 uses Git for version control with a structured branching strategy and conventional commit messages.

Key Principles: - Main branch always deployable - Feature branches for new work - Descriptive commit messages - Code review via pull requests - No direct commits to main

"},{"location":"v2/development/git-workflow/#branch-structure","title":"Branch Structure","text":""},{"location":"v2/development/git-workflow/#main-branches","title":"Main Branches","text":"

main - Production branch - Always deployable - Protected (no direct pushes) - Merges only via pull request - Tagged with version numbers

v2 - Development branch - Active development happens here - Features merge into v2 first - Tested before merging to main - Currently the primary development branch

"},{"location":"v2/development/git-workflow/#feature-branches","title":"Feature Branches","text":"

Naming: feature/<descriptive-name>

# Create feature branch from v2\ngit checkout v2\ngit pull origin v2\ngit checkout -b feature/add-user-avatar\n\n# Make changes\n# ...\n\n# Push to remote\ngit push -u origin feature/add-user-avatar\n

Examples: - feature/add-user-avatar - feature/email-queue-monitoring - feature/map-clustering - feature/campaign-analytics

"},{"location":"v2/development/git-workflow/#bugfix-branches","title":"Bugfix Branches","text":"

Naming: fix/<descriptive-name>

# Create bugfix branch\ngit checkout v2\ngit pull origin v2\ngit checkout -b fix/login-redirect-loop\n\n# Fix bug\n# ...\n\n# Push to remote\ngit push -u origin fix/login-redirect-loop\n

Examples: - fix/login-redirect-loop - fix/map-marker-position - fix/email-template-rendering

"},{"location":"v2/development/git-workflow/#hotfix-branches","title":"Hotfix Branches","text":"

Naming: hotfix/<descriptive-name>

For urgent production fixes:

# Create from main (production)\ngit checkout main\ngit pull origin main\ngit checkout -b hotfix/security-patch\n\n# Fix issue\n# ...\n\n# Merge to main AND v2\ngit checkout main\ngit merge hotfix/security-patch\ngit push origin main\n\ngit checkout v2\ngit merge hotfix/security-patch\ngit push origin v2\n

Examples: - hotfix/security-patch - hotfix/critical-database-error

"},{"location":"v2/development/git-workflow/#release-branches","title":"Release Branches","text":"

Naming: release/vX.Y.Z

For preparing releases:

# Create release branch from v2\ngit checkout v2\ngit pull origin v2\ngit checkout -b release/v2.1.0\n\n# Prepare release (update version, changelog)\n# Test thoroughly\n# ...\n\n# Merge to main (after approval)\ngit checkout main\ngit merge release/v2.1.0\ngit tag v2.1.0\ngit push origin main --tags\n\n# Merge back to v2\ngit checkout v2\ngit merge release/v2.1.0\ngit push origin v2\n
"},{"location":"v2/development/git-workflow/#feature-development-workflow","title":"Feature Development Workflow","text":""},{"location":"v2/development/git-workflow/#step-1-create-branch","title":"Step 1: Create Branch","text":"
# Update v2 branch\ngit checkout v2\ngit pull origin v2\n\n# Create feature branch\ngit checkout -b feature/add-user-avatar\n\n# Verify branch\ngit branch --show-current\n# Output: feature/add-user-avatar\n
"},{"location":"v2/development/git-workflow/#step-2-make-changes","title":"Step 2: Make Changes","text":"

Edit files, test locally:

# Make changes\nvi api/src/modules/users/users.service.ts\nvi admin/src/pages/UsersPage.tsx\n\n# Test locally\nnpm run dev\n\n# Type-check\nnpm run type-check\n\n# Lint\nnpm run lint:fix\n
"},{"location":"v2/development/git-workflow/#step-3-stage-and-commit","title":"Step 3: Stage and Commit","text":"
# Check status\ngit status\n\n# Stage specific files (NOT git add .)\ngit add api/src/modules/users/users.service.ts\ngit add admin/src/pages/UsersPage.tsx\n\n# Commit with conventional message\ngit commit -m \"feat(users): add avatar upload functionality\n\nImplements avatar upload with image validation and S3 storage.\nAdds avatar field to User model and updates UI.\n\nCloses #123\"\n
"},{"location":"v2/development/git-workflow/#step-4-push-to-remote","title":"Step 4: Push to Remote","text":"
# Push branch (first time)\ngit push -u origin feature/add-user-avatar\n\n# Push subsequent commits\ngit push\n
"},{"location":"v2/development/git-workflow/#step-5-create-pull-request","title":"Step 5: Create Pull Request","text":"

On GitHub/GitLab:

  1. Navigate to repository
  2. Click \"New Pull Request\"
  3. Select base: v2, compare: feature/add-user-avatar
  4. Fill in PR template (title, description, testing steps)
  5. Request reviewers
  6. Link related issues
"},{"location":"v2/development/git-workflow/#step-6-address-review-feedback","title":"Step 6: Address Review Feedback","text":"
# Make requested changes\nvi api/src/modules/users/users.service.ts\n\n# Stage and commit\ngit add api/src/modules/users/users.service.ts\ngit commit -m \"fix(users): address review feedback\n\n- Add error handling for upload failures\n- Improve validation messages\n- Add JSDoc comments\"\n\n# Push changes\ngit push\n
"},{"location":"v2/development/git-workflow/#step-7-merge-after-approval","title":"Step 7: Merge (After Approval)","text":"

Squash and Merge (recommended): - Combines all commits into one - Clean history on v2 branch - Preserves individual commits in branch

Merge Commit: - Preserves all commits - More detailed history - Use for large features

Rebase and Merge: - Linear history - No merge commits - Use when v2 has diverged

"},{"location":"v2/development/git-workflow/#step-8-clean-up","title":"Step 8: Clean Up","text":"
# Delete local branch\ngit checkout v2\ngit branch -d feature/add-user-avatar\n\n# Delete remote branch (if not auto-deleted)\ngit push origin --delete feature/add-user-avatar\n\n# Update v2\ngit pull origin v2\n
"},{"location":"v2/development/git-workflow/#commit-messages","title":"Commit Messages","text":""},{"location":"v2/development/git-workflow/#conventional-commits-format","title":"Conventional Commits Format","text":"
<type>(<scope>): <subject>\n\n<body>\n\n<footer>\n
"},{"location":"v2/development/git-workflow/#types","title":"Types","text":""},{"location":"v2/development/git-workflow/#scopes","title":"Scopes","text":"

Use module/area name:

"},{"location":"v2/development/git-workflow/#examples","title":"Examples","text":"

Simple commit:

git commit -m \"feat(auth): add JWT refresh token rotation\"\n

With body:

git commit -m \"feat(campaigns): add email queue monitoring\n\nImplements real-time queue stats dashboard with pause/resume controls.\nShows pending, active, completed, and failed jobs.\n\nCloses #45\"\n

Breaking change:

git commit -m \"feat(api)!: change user endpoint response format\n\nBREAKING CHANGE: User endpoint now returns paginated response.\nUpdate client code to handle new format.\n\nMigration guide: docs/migration/v2.1.md\"\n

Multiple changes:

git commit -m \"feat(map): add location clustering and popup improvements\n\n- Implement marker clustering for better performance\n- Add custom popup with location details\n- Improve map controls layout\n\nCloses #67, #68\"\n

Hotfix:

git commit -m \"fix(auth)!: patch critical security vulnerability\n\nFixes CVE-2024-12345 in JWT token validation.\nAll users must update tokens after deploy.\n\nSecurity advisory: docs/security/2024-02-13.md\"\n

"},{"location":"v2/development/git-workflow/#git-safety-protocol","title":"Git Safety Protocol","text":"

From CLAUDE.md - Critical Rules:

"},{"location":"v2/development/git-workflow/#never-do-these-unless-user-explicitly-requests","title":"NEVER Do These (Unless User Explicitly Requests)","text":"
# \u274c NEVER without explicit user approval\ngit push --force\ngit push --force-with-lease\ngit reset --hard\ngit checkout .\ngit restore .\ngit clean -f\ngit clean -fd\ngit branch -D\ngit rebase -i\n\n# \u274c NEVER skip hooks\ngit commit --no-verify\ngit push --no-verify\n\n# \u274c NEVER force push to main/master\ngit push --force origin main  # DANGER!\n
"},{"location":"v2/development/git-workflow/#always-do-these","title":"ALWAYS Do These","text":"
# \u2705 Stage specific files (not git add .)\ngit add api/src/modules/auth/auth.service.ts\ngit add admin/src/pages/LoginPage.tsx\n\n# \u2705 Create NEW commits (not --amend after hook failure)\n# If pre-commit hook fails, commit did NOT happen\n# Fix issue, re-stage, create NEW commit (not amend)\n\n# \u2705 Verify changes before commit\ngit diff --staged\n\n# \u2705 Pull before push\ngit pull origin v2\ngit push origin feature/my-feature\n
"},{"location":"v2/development/git-workflow/#commit-co-authoring-claude-code","title":"Commit Co-Authoring (Claude Code)","text":"

When Claude assists with code, add co-author:

git commit -m \"$(cat <<'EOF'\nfeat(auth): implement refresh token rotation\n\nAdds atomic refresh token rotation to prevent race conditions\nduring concurrent refresh requests.\n\nCo-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>\nEOF\n)\"\n

Or use heredoc:

git commit -m \"feat(auth): implement refresh token rotation\n\nAdds atomic refresh token rotation to prevent race conditions.\n\nCo-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>\"\n

"},{"location":"v2/development/git-workflow/#pull-request-process","title":"Pull Request Process","text":""},{"location":"v2/development/git-workflow/#pr-template","title":"PR Template","text":"

Create .github/pull_request_template.md:

## Description\n<!-- Brief description of changes -->\n\n## Type of Change\n- [ ] Bug fix (non-breaking change which fixes an issue)\n- [ ] New feature (non-breaking change which adds functionality)\n- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)\n- [ ] Documentation update\n\n## Related Issues\n<!-- Link to issue(s): Closes #123, Fixes #456 -->\n\n## Changes Made\n<!-- Detailed list of changes -->\n-\n-\n-\n\n## Testing Done\n<!-- How were these changes tested? -->\n- [ ] Unit tests added/updated\n- [ ] Integration tests added/updated\n- [ ] Manual testing performed\n\n## Checklist\n- [ ] Code follows style guidelines\n- [ ] Self-review completed\n- [ ] Comments added for complex logic\n- [ ] Documentation updated\n- [ ] No new warnings generated\n- [ ] Tests pass locally\n- [ ] Database migrations included (if applicable)\n
"},{"location":"v2/development/git-workflow/#code-review-checklist","title":"Code Review Checklist","text":"

Reviewer checks:

"},{"location":"v2/development/git-workflow/#review-process","title":"Review Process","text":"
  1. Author submits PR
  2. Fills out template
  3. Self-reviews changes
  4. Requests reviewers

  5. Reviewers review

  6. Read description
  7. Review code changes
  8. Test locally (if needed)
  9. Leave comments/suggestions

  10. Author addresses feedback

  11. Makes requested changes
  12. Responds to comments
  13. Re-requests review

  14. Final approval

  15. Reviewers approve
  16. CI/CD checks pass
  17. Merge to base branch
"},{"location":"v2/development/git-workflow/#merge-strategies","title":"Merge Strategies","text":""},{"location":"v2/development/git-workflow/#squash-and-merge-recommended","title":"Squash and Merge (Recommended)","text":"

When to use: - Feature branches with multiple commits - Want clean history on main/v2 - Individual commits not important

Result:

v2:  A---B---C---D\n                  \\\nfeature:           E---F---G  (squashed into D)\n

How:

# On GitHub: \"Squash and Merge\" button\n\n# Manual:\ngit checkout v2\ngit merge --squash feature/add-avatar\ngit commit -m \"feat(users): add avatar upload functionality\"\ngit push origin v2\n

"},{"location":"v2/development/git-workflow/#merge-commit","title":"Merge Commit","text":"

When to use: - Want to preserve all commits - Large features with meaningful commit history - Release branches

Result:

v2:  A---B-------D\n          \\     /\nfeature:   E---F\n

How:

# On GitHub: \"Create a merge commit\" button\n\n# Manual:\ngit checkout v2\ngit merge feature/add-avatar\ngit push origin v2\n

"},{"location":"v2/development/git-workflow/#rebase-and-merge","title":"Rebase and Merge","text":"

When to use: - Want linear history - Few commits - No merge conflicts

Result:

v2:  A---B---E'---F'\n

How:

# On GitHub: \"Rebase and merge\" button\n\n# Manual:\ngit checkout feature/add-avatar\ngit rebase v2\ngit checkout v2\ngit merge feature/add-avatar\ngit push origin v2\n

"},{"location":"v2/development/git-workflow/#version-tags","title":"Version Tags","text":""},{"location":"v2/development/git-workflow/#semantic-versioning","title":"Semantic Versioning","text":"

Format: vMAJOR.MINOR.PATCH

Examples: - v2.0.0 - Major release (V2 launch) - v2.1.0 - New features added - v2.1.1 - Bug fixes - v2.2.0 - More new features

"},{"location":"v2/development/git-workflow/#creating-tags","title":"Creating Tags","text":"
# Create annotated tag\ngit tag -a v2.1.0 -m \"Release v2.1.0: Email queue monitoring\n\nNew Features:\n- Email queue dashboard\n- Pause/resume controls\n- Job statistics\n\nBug Fixes:\n- Fixed map marker positioning\n- Fixed login redirect loop\n\nSee CHANGELOG.md for full details\"\n\n# Push tag to remote\ngit push origin v2.1.0\n\n# Push all tags\ngit push origin --tags\n
"},{"location":"v2/development/git-workflow/#viewing-tags","title":"Viewing Tags","text":"
# List all tags\ngit tag\n\n# List tags matching pattern\ngit tag -l \"v2.1.*\"\n\n# Show tag details\ngit show v2.1.0\n\n# Checkout specific tag\ngit checkout v2.1.0\n
"},{"location":"v2/development/git-workflow/#common-operations","title":"Common Operations","text":""},{"location":"v2/development/git-workflow/#update-branch-with-latest-v2","title":"Update Branch with Latest v2","text":"
# While on feature branch\ngit checkout feature/add-avatar\ngit fetch origin\ngit rebase origin/v2\n\n# Or merge (if rebase has conflicts)\ngit merge origin/v2\n
"},{"location":"v2/development/git-workflow/#resolve-merge-conflicts","title":"Resolve Merge Conflicts","text":"
# Attempt merge/rebase\ngit merge v2\n# CONFLICT (content): Merge conflict in api/src/modules/auth/auth.service.ts\n\n# View conflicted files\ngit status\n\n# Edit conflicted file\nvi api/src/modules/auth/auth.service.ts\n\n# Look for conflict markers:\n# <<<<<<< HEAD\n# Your changes\n# =======\n# Their changes\n# >>>>>>> v2\n\n# Resolve conflict, remove markers\n\n# Stage resolved file\ngit add api/src/modules/auth/auth.service.ts\n\n# Continue merge\ngit commit\n# Or continue rebase\ngit rebase --continue\n
"},{"location":"v2/development/git-workflow/#undo-changes","title":"Undo Changes","text":"
# Unstage file\ngit restore --staged api/src/modules/auth/auth.service.ts\n\n# Discard local changes (CAREFUL!)\ngit restore api/src/modules/auth/auth.service.ts\n\n# Undo last commit (keep changes)\ngit reset --soft HEAD~1\n\n# Undo last commit (discard changes)\ngit reset --hard HEAD~1  # \u26a0\ufe0f DESTRUCTIVE!\n\n# Revert commit (creates new commit)\ngit revert abc123  # Safer than reset\n
"},{"location":"v2/development/git-workflow/#stash-changes","title":"Stash Changes","text":"
# Stash uncommitted changes\ngit stash\n\n# Stash with message\ngit stash save \"WIP: avatar upload\"\n\n# List stashes\ngit stash list\n\n# Apply stash\ngit stash apply\n\n# Apply specific stash\ngit stash apply stash@{1}\n\n# Pop stash (apply and delete)\ngit stash pop\n\n# Delete stash\ngit stash drop stash@{0}\n
"},{"location":"v2/development/git-workflow/#view-history","title":"View History","text":"
# View commit history\ngit log\n\n# One-line format\ngit log --oneline\n\n# Graph view\ngit log --oneline --graph --all\n\n# Filter by author\ngit log --author=\"John Doe\"\n\n# Filter by date\ngit log --since=\"2024-01-01\" --until=\"2024-12-31\"\n\n# File history\ngit log --follow api/src/modules/auth/auth.service.ts\n\n# Search commits\ngit log --grep=\"JWT\"\n
"},{"location":"v2/development/git-workflow/#compare-changes","title":"Compare Changes","text":"
# Compare working directory to staging\ngit diff\n\n# Compare staging to last commit\ngit diff --staged\n\n# Compare two branches\ngit diff v2..feature/add-avatar\n\n# Compare specific file\ngit diff v2 api/src/modules/auth/auth.service.ts\n\n# Compare commits\ngit diff abc123..def456\n
"},{"location":"v2/development/git-workflow/#git-hooks","title":"Git Hooks","text":""},{"location":"v2/development/git-workflow/#pre-commit-hook","title":"Pre-commit Hook","text":"

Install husky:

npm install --save-dev husky lint-staged\nnpx husky install\n

Create pre-commit hook:

# .husky/pre-commit\n#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\n# Run lint-staged\nnpx lint-staged\n

Configure lint-staged (package.json):

{\n  \"lint-staged\": {\n    \"*.{ts,tsx}\": [\n      \"eslint --fix\",\n      \"prettier --write\"\n    ],\n    \"*.{json,md}\": [\n      \"prettier --write\"\n    ]\n  }\n}\n

Hook runs automatically:

git commit -m \"feat: add feature\"\n# Runs ESLint, Prettier on staged files\n# Fails commit if errors found\n
"},{"location":"v2/development/git-workflow/#commit-msg-hook","title":"Commit-msg Hook","text":"

Validate commit message format:

# .husky/commit-msg\n#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\n# Validate conventional commit format\nnpx commitlint --edit $1\n

Install commitlint:

npm install --save-dev @commitlint/cli @commitlint/config-conventional\n

Configure (.commitlintrc.json):

{\n  \"extends\": [\"@commitlint/config-conventional\"],\n  \"rules\": {\n    \"type-enum\": [\n      2,\n      \"always\",\n      [\"feat\", \"fix\", \"docs\", \"style\", \"refactor\", \"perf\", \"test\", \"chore\", \"ci\", \"build\"]\n    ],\n    \"scope-enum\": [\n      2,\n      \"always\",\n      [\"auth\", \"users\", \"campaigns\", \"map\", \"email\", \"db\", \"ui\", \"api\"]\n    ]\n  }\n}\n
"},{"location":"v2/development/git-workflow/#gitignore","title":".gitignore","text":"

Project .gitignore:

# Dependencies\nnode_modules/\n*/node_modules/\n\n# Build outputs\ndist/\nbuild/\n*/dist/\n*/build/\n\n# Environment\n.env\n.env.local\n.env.*.local\n\n# Logs\nlogs/\n*.log\nnpm-debug.log*\n\n# IDE\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\n\n# OS\n.DS_Store\nThumbs.db\n\n# Testing\ncoverage/\n.nyc_output/\n\n# Temporary\ntmp/\ntemp/\n*.tmp\n\n# Database\n*.sqlite\n*.db\n\n# Prisma\napi/.prisma/\napi/prisma/.env\n\n# Vite\nadmin/.vite/\nadmin/tsconfig.tsbuildinfo\n\n# Docker volumes\npostgres-data/\nredis-data/\n
"},{"location":"v2/development/git-workflow/#collaboration","title":"Collaboration","text":""},{"location":"v2/development/git-workflow/#forks","title":"Forks","text":"

Fork workflow:

  1. Fork repository on GitHub
  2. Clone your fork:

    git clone https://github.com/your-username/changemaker.lite.git\n

  3. Add upstream remote:

    git remote add upstream https://github.com/original/changemaker.lite.git\n

  4. Create feature branch:

    git checkout -b feature/my-feature\n

  5. Make changes, commit, push to your fork:

    git push origin feature/my-feature\n

  6. Create pull request from your fork to upstream

"},{"location":"v2/development/git-workflow/#sync-fork-with-upstream","title":"Sync Fork with Upstream","text":"
# Fetch upstream changes\ngit fetch upstream\n\n# Merge upstream v2 into your v2\ngit checkout v2\ngit merge upstream/v2\n\n# Push to your fork\ngit push origin v2\n
"},{"location":"v2/development/git-workflow/#best-practices","title":"Best Practices","text":""},{"location":"v2/development/git-workflow/#dos","title":"Do's","text":""},{"location":"v2/development/git-workflow/#donts","title":"Don'ts","text":""},{"location":"v2/development/git-workflow/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/development/git-workflow/#summary","title":"Summary","text":"

You now know: - \u2705 Branch structure (main, v2, feature, fix, hotfix) - \u2705 Feature development workflow - \u2705 Conventional commit message format - \u2705 Git safety protocol (NEVER force push without approval) - \u2705 Pull request process - \u2705 Merge strategies (squash, merge commit, rebase) - \u2705 Version tagging (semantic versioning) - \u2705 Common Git operations - \u2705 Git hooks (pre-commit, commit-msg) - \u2705 Best practices

Quick Reference:

# Create feature branch\ngit checkout -b feature/my-feature\n\n# Make changes, stage, commit\ngit add specific-file.ts\ngit commit -m \"feat(scope): description\"\n\n# Push and create PR\ngit push -u origin feature/my-feature\n\n# After merge, clean up\ngit checkout v2\ngit pull origin v2\ngit branch -d feature/my-feature\n

"},{"location":"v2/development/local-setup/","title":"Local Development Setup","text":"

This guide walks you through setting up Changemaker Lite V2 for local development on your machine.

"},{"location":"v2/development/local-setup/#overview","title":"Overview","text":"

Changemaker Lite V2 supports two development approaches:

  1. Docker-based development - Run API and Admin in containers (recommended for consistency)
  2. Local npm development - Run services directly on your host machine (faster hot reload)

This guide covers both approaches. Choose the one that fits your workflow.

"},{"location":"v2/development/local-setup/#prerequisites","title":"Prerequisites","text":""},{"location":"v2/development/local-setup/#required-software","title":"Required Software","text":""},{"location":"v2/development/local-setup/#nodejs-and-npm","title":"Node.js and npm","text":"
# Check versions\nnode --version  # Should be v20.x.x or higher\nnpm --version   # Should be 10.x.x or higher\n

Installation: - Download from nodejs.org - Or use nvm for version management:

nvm install 20\nnvm use 20\n
"},{"location":"v2/development/local-setup/#docker-and-docker-compose","title":"Docker and Docker Compose","text":"
# Check versions\ndocker --version         # Should be 24.x.x or higher\ndocker compose version   # Should be 2.x.x or higher\n

Installation: - Docker Desktop: docker.com/get-started - Linux: docs.docker.com/engine/install

"},{"location":"v2/development/local-setup/#git","title":"Git","text":"
# Check version\ngit --version  # Should be 2.30.x or higher\n

Installation: - Download from git-scm.com - Or use package manager (apt, brew, etc.)

"},{"location":"v2/development/local-setup/#optional-tools","title":"Optional Tools","text":""},{"location":"v2/development/local-setup/#postgresql-client-tools","title":"PostgreSQL Client Tools","text":"

Useful for database inspection and debugging:

# Ubuntu/Debian\nsudo apt install postgresql-client\n\n# macOS\nbrew install postgresql@16\n\n# Check installation\npsql --version\n
"},{"location":"v2/development/local-setup/#redis-cli","title":"Redis CLI","text":"

For cache/queue debugging:

# Ubuntu/Debian\nsudo apt install redis-tools\n\n# macOS\nbrew install redis\n\n# Check installation\nredis-cli --version\n
"},{"location":"v2/development/local-setup/#visual-studio-code","title":"Visual Studio Code","text":"

Recommended IDE with excellent TypeScript support:

"},{"location":"v2/development/local-setup/#system-requirements","title":"System Requirements","text":"

Minimum: - 8 GB RAM - 20 GB free disk space - 2 CPU cores

Recommended: - 16 GB RAM - 50 GB free disk space - 4+ CPU cores

"},{"location":"v2/development/local-setup/#repository-setup","title":"Repository Setup","text":""},{"location":"v2/development/local-setup/#clone-repository","title":"Clone Repository","text":"
# Clone the repository\ngit clone <repo-url> changemaker.lite\ncd changemaker.lite\n\n# Checkout v2 branch\ngit checkout v2\n\n# Verify branch\ngit branch --show-current\n# Output: v2\n
"},{"location":"v2/development/local-setup/#repository-structure","title":"Repository Structure","text":"

After cloning, your directory structure should look like:

changemaker.lite/\n\u251c\u2500\u2500 api/                  # Express.js + Fastify backend\n\u251c\u2500\u2500 admin/                # React frontend\n\u251c\u2500\u2500 configs/              # Monitoring configs (Prometheus, Grafana)\n\u251c\u2500\u2500 nginx/                # Reverse proxy configuration\n\u251c\u2500\u2500 scripts/              # Utility scripts\n\u251c\u2500\u2500 docker-compose.yml    # V2 orchestration\n\u251c\u2500\u2500 .env.example          # Environment template\n\u2514\u2500\u2500 V2_PLAN.md           # Development roadmap\n
"},{"location":"v2/development/local-setup/#verify-files","title":"Verify Files","text":"

Check that key files exist:

ls -la api/package.json admin/package.json docker-compose.yml .env.example\n

If any files are missing, ensure you're on the v2 branch.

"},{"location":"v2/development/local-setup/#environment-configuration","title":"Environment Configuration","text":""},{"location":"v2/development/local-setup/#create-env-file","title":"Create .env File","text":"

Copy the example environment file:

cp .env.example .env\n
"},{"location":"v2/development/local-setup/#configure-essential-variables","title":"Configure Essential Variables","text":"

Open .env in your editor and set the following critical variables:

"},{"location":"v2/development/local-setup/#database-passwords","title":"Database Passwords","text":"
# PostgreSQL password (use a strong password)\nV2_POSTGRES_PASSWORD=your_strong_password_here\n\n# Redis password (use a strong password)\nREDIS_PASSWORD=your_redis_password_here\n
"},{"location":"v2/development/local-setup/#jwt-secrets","title":"JWT Secrets","text":"

Generate secure random secrets:

# Generate secrets (run these commands separately)\nopenssl rand -hex 32  # For JWT_ACCESS_SECRET\nopenssl rand -hex 32  # For JWT_REFRESH_SECRET\nopenssl rand -hex 32  # For ENCRYPTION_KEY\n

Add to .env:

# JWT secrets (use different values for each!)\nJWT_ACCESS_SECRET=<output from first command>\nJWT_REFRESH_SECRET=<output from second command>\nENCRYPTION_KEY=<output from third command>\n

IMPORTANT: All three secrets must be different values!

"},{"location":"v2/development/local-setup/#email-configuration-development","title":"Email Configuration (Development)","text":"

For development, use MailHog to capture emails locally:

# Email test mode (sends to MailHog instead of real SMTP)\nEMAIL_TEST_MODE=true\n\n# MailHog SMTP settings\nEMAIL_SMTP_HOST=localhost\nEMAIL_SMTP_PORT=1025\nEMAIL_SMTP_SECURE=false\nEMAIL_FROM_ADDRESS=noreply@cmlite.org\nEMAIL_FROM_NAME=Changemaker Lite\n
"},{"location":"v2/development/local-setup/#optional-features","title":"Optional Features","text":"

Enable optional features as needed:

# Media Manager (video library)\nENABLE_MEDIA_FEATURES=true\n\n# Listmonk newsletter sync\nLISTMONK_SYNC_ENABLED=false  # Enable later if needed\n\n# API ports (defaults work for most setups)\nAPI_PORT=4000\nADMIN_PORT=3000\nMEDIA_API_PORT=4100\n
"},{"location":"v2/development/local-setup/#complete-env-template","title":"Complete .env Template","text":"

Here's a minimal .env for local development:

# Database\nV2_POSTGRES_PASSWORD=your_strong_password\nDATABASE_URL=postgresql://changemaker_v2:your_strong_password@localhost:5433/changemaker_v2_db\n\n# Redis\nREDIS_PASSWORD=your_redis_password\nREDIS_URL=redis://:your_redis_password@localhost:6379\n\n# JWT\nJWT_ACCESS_SECRET=<32-byte hex from openssl>\nJWT_REFRESH_SECRET=<32-byte hex from openssl>\nENCRYPTION_KEY=<32-byte hex from openssl>\n\n# Email (MailHog for dev)\nEMAIL_TEST_MODE=true\nEMAIL_SMTP_HOST=localhost\nEMAIL_SMTP_PORT=1025\nEMAIL_SMTP_SECURE=false\nEMAIL_FROM_ADDRESS=noreply@cmlite.org\nEMAIL_FROM_NAME=Changemaker Lite\n\n# Ports\nAPI_PORT=4000\nADMIN_PORT=3000\nMEDIA_API_PORT=4100\n\n# Features\nENABLE_MEDIA_FEATURES=true\nLISTMONK_SYNC_ENABLED=false\n\n# Node environment\nNODE_ENV=development\n
"},{"location":"v2/development/local-setup/#verify-configuration","title":"Verify Configuration","text":"

Check that required variables are set:

grep -E '^(V2_POSTGRES_PASSWORD|REDIS_PASSWORD|JWT_ACCESS_SECRET|JWT_REFRESH_SECRET|ENCRYPTION_KEY)=' .env\n

You should see 5 lines with non-empty values.

"},{"location":"v2/development/local-setup/#database-setup","title":"Database Setup","text":""},{"location":"v2/development/local-setup/#start-database-services","title":"Start Database Services","text":"

Start PostgreSQL and Redis containers:

docker compose up -d v2-postgres redis\n

Wait for databases to initialize (first run takes 30-60 seconds):

# Watch logs\ndocker compose logs -f v2-postgres redis\n\n# Look for:\n# v2-postgres: \"database system is ready to accept connections\"\n# redis: \"Ready to accept connections\"\n\n# Press Ctrl+C to exit logs\n
"},{"location":"v2/development/local-setup/#verify-database-connection","title":"Verify Database Connection","text":"

Test PostgreSQL connection:

docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"SELECT version();\"\n

You should see PostgreSQL version information.

Test Redis connection:

docker compose exec redis redis-cli -a your_redis_password ping\n# Output: PONG\n
"},{"location":"v2/development/local-setup/#install-api-dependencies","title":"Install API Dependencies","text":"
cd api\nnpm install\n

Expected output: - Installs ~300+ packages - May show peer dependency warnings (safe to ignore) - Should complete without errors

"},{"location":"v2/development/local-setup/#run-database-migrations","title":"Run Database Migrations","text":"

Apply Prisma migrations to create database schema:

# From api/ directory\nnpx prisma migrate deploy\n

Expected output:

Environment variables loaded from .env\nPrisma schema loaded from prisma/schema.prisma\nDatasource \"db\": PostgreSQL database \"changemaker_v2_db\"\n\n20 migrations found in prisma/migrations\n\nApplying migration `20260101000000_init`\nApplying migration `20260105000000_add_campaigns`\n...\nAll migrations have been successfully applied.\n

"},{"location":"v2/development/local-setup/#seed-database","title":"Seed Database","text":"

Populate database with initial data (admin user, settings, etc.):

# From api/ directory\nnpx prisma db seed\n

Expected output:

Running seed command `tsx prisma/seed.ts` ...\nSeeding database...\nCreated default settings\nCreated admin user: admin@example.com\nCreated 10 sample blocks\n...\nSeed completed successfully\n

Default Admin Credentials: - Email: admin@example.com - Password: Admin123! - Change this password immediately after first login!

"},{"location":"v2/development/local-setup/#verify-database-schema","title":"Verify Database Schema","text":"

Open Prisma Studio to browse the database:

# From api/ directory\nnpx prisma studio\n

This opens a browser at http://localhost:5555 showing: - 30+ tables (User, Campaign, Location, Shift, etc.) - Seeded data (1 admin user, settings, blocks)

Press Ctrl+C to close Prisma Studio when done.

"},{"location":"v2/development/local-setup/#return-to-project-root","title":"Return to Project Root","text":"
cd ..  # Back to changemaker.lite/\n
"},{"location":"v2/development/local-setup/#starting-services","title":"Starting Services","text":"

You have two options for running the development servers:

"},{"location":"v2/development/local-setup/#option-1-docker-based-development-recommended","title":"Option 1: Docker-based Development (Recommended)","text":"

Run API and Admin in Docker containers with volume mounts for hot reload:

# Start API and Admin containers\ndocker compose up -d api admin\n\n# Optional: Start MailHog for email testing\ndocker compose up -d mailhog\n\n# Optional: Start Media API\ndocker compose up -d media-api\n

Watch logs:

# All services\ndocker compose logs -f api admin\n\n# Just API\ndocker compose logs -f api\n\n# Just Admin\ndocker compose logs -f admin\n

Verify services started:

docker compose ps\n

You should see: - api - running on port 4000 - admin - running on port 3000 - v2-postgres - running on port 5433 - redis - running on port 6379 - mailhog - running on port 8025 (if started)

Hot Reload in Docker:

Volume mounts automatically sync code changes: - API: tsx watch restarts server on file changes - Admin: Vite HMR updates browser without full reload

"},{"location":"v2/development/local-setup/#option-2-local-npm-development","title":"Option 2: Local npm Development","text":"

Run services directly on your host machine (faster hot reload):

"},{"location":"v2/development/local-setup/#terminal-1-api-server","title":"Terminal 1: API Server","text":"
cd api\nnpm run dev\n

Expected output:

> api@2.0.0 dev\n> tsx watch src/server.ts\n\nServer running on port 4000\nDatabase connected\nRedis connected\nBullMQ worker started\n

"},{"location":"v2/development/local-setup/#terminal-2-admin-server","title":"Terminal 2: Admin Server","text":"
cd admin\nnpm install  # First time only\nnpm run dev\n

Expected output:

> admin@2.0.0 dev\n> vite\n\n  VITE v5.x.x  ready in 500 ms\n\n  \u279c  Local:   http://localhost:3000/\n  \u279c  Network: use --host to expose\n

"},{"location":"v2/development/local-setup/#terminal-3-media-api-optional","title":"Terminal 3: Media API (Optional)","text":"
cd api\nnpm run dev:media\n

Expected output:

> api@2.0.0 dev:media\n> tsx watch src/media-server.ts\n\nMedia API server running on port 4100\nDatabase connected\n

"},{"location":"v2/development/local-setup/#background-services","title":"Background Services","text":"

You still need Docker for PostgreSQL, Redis, and MailHog:

docker compose up -d v2-postgres redis mailhog\n
"},{"location":"v2/development/local-setup/#which-approach-to-use","title":"Which Approach to Use?","text":"

Use Docker-based development if: - You want consistent environment across team - You're new to the project - You prefer simpler setup

Use local npm development if: - You want faster hot reload (especially for frontend) - You're actively developing API changes - You prefer direct access to Node.js processes

You can mix approaches: - Run API in Docker, Admin locally - Run databases in Docker, both API/Admin locally

"},{"location":"v2/development/local-setup/#verifying-setup","title":"Verifying Setup","text":""},{"location":"v2/development/local-setup/#health-check-endpoints","title":"Health Check Endpoints","text":"

Test that services are responding:

# API health check\ncurl http://localhost:4000/health\n# Expected: {\"status\":\"ok\",\"timestamp\":\"2026-02-13T...\"}\n\n# Admin (open in browser)\nopen http://localhost:3000\n# Or visit manually: http://localhost:3000\n\n# Media API health check (if enabled)\ncurl http://localhost:4100/health\n# Expected: {\"status\":\"ok\",\"timestamp\":\"2026-02-13T...\"}\n
"},{"location":"v2/development/local-setup/#test-authentication","title":"Test Authentication","text":"

Test login endpoint:

curl -X POST http://localhost:4000/api/auth/login \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"email\": \"admin@example.com\",\n    \"password\": \"Admin123!\"\n  }'\n

Expected response:

{\n  \"accessToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n  \"refreshToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n  \"user\": {\n    \"id\": 1,\n    \"email\": \"admin@example.com\",\n    \"role\": \"SUPER_ADMIN\"\n  }\n}\n

"},{"location":"v2/development/local-setup/#login-to-admin-gui","title":"Login to Admin GUI","text":"
  1. Open http://localhost:3000 in browser
  2. Login with:
  3. Email: admin@example.com
  4. Password: Admin123!
  5. You should be redirected to /app (admin dashboard)
  6. Change password immediately:
  7. Click user menu (top right)
  8. Settings \u2192 Change Password
  9. Set new password (12+ chars, uppercase, lowercase, digit)
"},{"location":"v2/development/local-setup/#verify-database-connection_1","title":"Verify Database Connection","text":"

Check that API can query database:

curl http://localhost:4000/api/users \\\n  -H \"Authorization: Bearer <access_token_from_login>\"\n

Expected response:

{\n  \"users\": [\n    {\n      \"id\": 1,\n      \"email\": \"admin@example.com\",\n      \"role\": \"SUPER_ADMIN\",\n      ...\n    }\n  ],\n  \"total\": 1,\n  \"page\": 1,\n  \"limit\": 50\n}\n

"},{"location":"v2/development/local-setup/#test-email-capture-mailhog","title":"Test Email Capture (MailHog)","text":"
  1. Open http://localhost:8025 in browser
  2. You should see MailHog web UI
  3. Trigger a test email (e.g., shift signup)
  4. Email appears in MailHog inbox
"},{"location":"v2/development/local-setup/#ide-setup","title":"IDE Setup","text":""},{"location":"v2/development/local-setup/#visual-studio-code_1","title":"Visual Studio Code","text":"

Recommended IDE with excellent TypeScript/React support.

"},{"location":"v2/development/local-setup/#recommended-extensions","title":"Recommended Extensions","text":"

Install these extensions for best developer experience:

Essential: - ESLint (dbaeumer.vscode-eslint) - Linting - Prettier (esbenp.prettier-vscode) - Code formatting - Prisma (Prisma.prisma) - Prisma schema support - TypeScript Vue Plugin (Volar) (Vue.volar) - Vue/JSX support

Highly Recommended: - GitLens (eamodio.gitlens) - Git insights - Docker (ms-azuretools.vscode-docker) - Docker management - Thunder Client (rangav.vscode-thunder-client) - API testing - Error Lens (usernamehw.errorlens) - Inline errors - Auto Rename Tag (formulahendry.auto-rename-tag) - HTML/JSX tag pairs - Path Intellisense (christian-kohler.path-intellisense) - Path autocomplete

Optional: - Tailwind CSS IntelliSense (bradlc.vscode-tailwindcss) - Tailwind support - DotENV (mikestead.dotenv) - .env syntax highlighting - Import Cost (wix.vscode-import-cost) - Bundle size info

"},{"location":"v2/development/local-setup/#workspace-settings","title":"Workspace Settings","text":"

Create .vscode/settings.json in project root:

{\n  // Editor\n  \"editor.formatOnSave\": true,\n  \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n  \"editor.codeActionsOnSave\": {\n    \"source.fixAll.eslint\": true\n  },\n  \"editor.tabSize\": 2,\n  \"editor.insertSpaces\": true,\n\n  // Files\n  \"files.eol\": \"\\n\",\n  \"files.trimTrailingWhitespace\": true,\n  \"files.insertFinalNewline\": true,\n\n  // TypeScript\n  \"typescript.tsdk\": \"node_modules/typescript/lib\",\n  \"typescript.enablePromptUseWorkspaceTsdk\": true,\n  \"typescript.preferences.importModuleSpecifier\": \"relative\",\n\n  // Prisma\n  \"[prisma]\": {\n    \"editor.defaultFormatter\": \"Prisma.prisma\"\n  },\n\n  // ESLint\n  \"eslint.validate\": [\n    \"javascript\",\n    \"javascriptreact\",\n    \"typescript\",\n    \"typescriptreact\"\n  ],\n\n  // Search exclusions (performance)\n  \"search.exclude\": {\n    \"**/node_modules\": true,\n    \"**/dist\": true,\n    \"**/build\": true,\n    \"**/.git\": true,\n    \"**/coverage\": true\n  },\n\n  // File associations\n  \"files.associations\": {\n    \"*.css\": \"css\",\n    \".env*\": \"dotenv\"\n  }\n}\n
"},{"location":"v2/development/local-setup/#launch-configuration","title":"Launch Configuration","text":"

Create .vscode/launch.json for debugging:

{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"name\": \"Debug API\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"runtimeExecutable\": \"npm\",\n      \"runtimeArgs\": [\"run\", \"dev\"],\n      \"cwd\": \"${workspaceFolder}/api\",\n      \"console\": \"integratedTerminal\",\n      \"skipFiles\": [\"<node_internals>/**\"],\n      \"envFile\": \"${workspaceFolder}/.env\"\n    },\n    {\n      \"name\": \"Debug Admin (Chrome)\",\n      \"type\": \"chrome\",\n      \"request\": \"launch\",\n      \"url\": \"http://localhost:3000\",\n      \"webRoot\": \"${workspaceFolder}/admin/src\",\n      \"sourceMapPathOverrides\": {\n        \"webpack:///./src/*\": \"${webRoot}/*\"\n      }\n    },\n    {\n      \"name\": \"Debug Media API\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"runtimeExecutable\": \"npm\",\n      \"runtimeArgs\": [\"run\", \"dev:media\"],\n      \"cwd\": \"${workspaceFolder}/api\",\n      \"console\": \"integratedTerminal\",\n      \"skipFiles\": [\"<node_internals>/**\"],\n      \"envFile\": \"${workspaceFolder}/.env\"\n    }\n  ]\n}\n
"},{"location":"v2/development/local-setup/#workspace-file","title":"Workspace File","text":"

Create changemaker-lite.code-workspace:

{\n  \"folders\": [\n    {\n      \"name\": \"Root\",\n      \"path\": \".\"\n    },\n    {\n      \"name\": \"API\",\n      \"path\": \"api\"\n    },\n    {\n      \"name\": \"Admin\",\n      \"path\": \"admin\"\n    }\n  ],\n  \"settings\": {\n    // Workspace-level settings (inherits from .vscode/settings.json)\n  }\n}\n

Open workspace: code changemaker-lite.code-workspace

"},{"location":"v2/development/local-setup/#other-ides","title":"Other IDEs","text":""},{"location":"v2/development/local-setup/#webstorm-intellij-idea","title":"WebStorm / IntelliJ IDEA","text":""},{"location":"v2/development/local-setup/#neovim-vim","title":"Neovim / Vim","text":""},{"location":"v2/development/local-setup/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/development/local-setup/#port-conflicts","title":"Port Conflicts","text":"

Problem: Port already in use errors

Error: listen EADDRINUSE: address already in use :::4000\n

Solution 1: Find and kill the process using the port

# Linux/macOS\nlsof -ti:4000 | xargs kill -9\n\n# Or change port in .env\nAPI_PORT=4002\n

Solution 2: Use different ports in .env

API_PORT=4002\nADMIN_PORT=3002\nMEDIA_API_PORT=4102\n
"},{"location":"v2/development/local-setup/#database-connection-errors","title":"Database Connection Errors","text":"

Problem: API cannot connect to PostgreSQL

Error: connect ECONNREFUSED 127.0.0.1:5433\n

Solution 1: Verify PostgreSQL is running

docker compose ps v2-postgres\n# Should show \"running\"\n

Solution 2: Check DATABASE_URL in .env

# Should match your password and port\nDATABASE_URL=postgresql://changemaker_v2:your_password@localhost:5433/changemaker_v2_db\n

Solution 3: Restart PostgreSQL container

docker compose restart v2-postgres\ndocker compose logs -f v2-postgres\n# Wait for \"ready to accept connections\"\n
"},{"location":"v2/development/local-setup/#redis-connection-errors","title":"Redis Connection Errors","text":"

Problem: API cannot connect to Redis

Error: Redis connection refused\n

Solution 1: Verify Redis is running

docker compose ps redis\n# Should show \"running\"\n

Solution 2: Check REDIS_URL and password

# Should match your password\nREDIS_URL=redis://:your_redis_password@localhost:6379\nREDIS_PASSWORD=your_redis_password\n

Solution 3: Test Redis connection directly

docker compose exec redis redis-cli -a your_redis_password ping\n# Should output: PONG\n
"},{"location":"v2/development/local-setup/#migration-errors","title":"Migration Errors","text":"

Problem: Prisma migration fails

Error: P3005 Database schema is not empty\n

Solution 1: Reset database (DEVELOPMENT ONLY)

cd api\nnpx prisma migrate reset\n# WARNING: This deletes all data!\n

Solution 2: Force deploy migrations

cd api\nnpx prisma migrate deploy --force\n

Solution 3: Check migration history

cd api\nnpx prisma migrate status\n
"},{"location":"v2/development/local-setup/#npm-install-failures","title":"npm Install Failures","text":"

Problem: npm install fails with permission errors

Solution 1: Clear npm cache

npm cache clean --force\nrm -rf node_modules package-lock.json\nnpm install\n

Solution 2: Use correct Node.js version

node --version  # Should be v20.x.x\nnvm use 20\n

Solution 3: Check disk space

df -h\n# Ensure sufficient space (10GB+ free)\n
"},{"location":"v2/development/local-setup/#hot-reload-not-working","title":"Hot Reload Not Working","text":"

Problem: Code changes don't trigger reload

Solution 1 (Docker): Verify volume mounts in docker-compose.yml

api:\n  volumes:\n    - ./api:/app  # Must be present\n

Solution 2 (Local): Restart dev server

# Stop (Ctrl+C) and restart\nnpm run dev\n

Solution 3 (Admin/Vite): Clear Vite cache

cd admin\nrm -rf node_modules/.vite\nnpm run dev\n
"},{"location":"v2/development/local-setup/#admin-build-errors","title":"Admin Build Errors","text":"

Problem: TypeScript errors on build

error TS2339: Property 'foo' does not exist on type 'Bar'\n

Solution 1: Type-check without emit

cd admin\nnpx tsc --noEmit\n# Shows all type errors\n

Solution 2: Update type definitions

cd admin\nnpm install --save-dev @types/react@latest @types/react-dom@latest\n

Solution 3: Check tsconfig.json

cd admin\ncat tsconfig.json\n# Ensure \"strict\": true and \"skipLibCheck\": false\n
"},{"location":"v2/development/local-setup/#docker-container-crashes","title":"Docker Container Crashes","text":"

Problem: API/Admin container exits immediately

Solution 1: Check logs

docker compose logs api\n# Look for error messages\n

Solution 2: Verify .env file exists

ls -la .env\n# Should exist in project root\n

Solution 3: Rebuild containers

docker compose down\ndocker compose build --no-cache api admin\ndocker compose up -d api admin\n
"},{"location":"v2/development/local-setup/#browser-cors-errors","title":"Browser CORS Errors","text":"

Problem: Admin cannot call API (CORS errors in browser console)

Solution 1: Check CORS_ORIGIN in .env

# For local development\nCORS_ORIGIN=http://localhost:3000\n

Solution 2: Verify API_URL in admin

For Docker-based API, admin vite.config.ts proxy should work automatically.

For local API, ensure VITE_API_URL is NOT set (defaults to localhost:4000).

Solution 3: Clear browser cache

"},{"location":"v2/development/local-setup/#hot-reload","title":"Hot Reload","text":""},{"location":"v2/development/local-setup/#api-hot-reload-tsx-watch","title":"API Hot Reload (tsx watch)","text":"

API uses tsx watch for automatic restart on file changes:

# Started automatically with npm run dev\ncd api\nnpm run dev\n

What triggers reload: - Changes to .ts files in src/ - Changes to .prisma files (after running migrate)

What does NOT trigger reload: - Changes to .env (restart manually) - Changes to node_modules/ (reinstall packages)

Manual restart:

# If using npm run dev, just Ctrl+C and restart\nnpm run dev\n

"},{"location":"v2/development/local-setup/#admin-hot-reload-vite-hmr","title":"Admin Hot Reload (Vite HMR)","text":"

Admin uses Vite's Hot Module Replacement (HMR):

cd admin\nnpm run dev\n

What triggers HMR: - Changes to .tsx / .ts files - Changes to .css files - Changes to imported assets

HMR Behavior: - Component changes: Updates without full reload - Hook changes: May require full reload - Route changes: Full reload

Force full reload: - Press r in terminal running Vite - Or refresh browser (Cmd+R / Ctrl+R)

"},{"location":"v2/development/local-setup/#docker-hot-reload","title":"Docker Hot Reload","text":"

Docker volume mounts enable hot reload in containers:

# docker-compose.yml\napi:\n  volumes:\n    - ./api:/app      # Syncs code changes\n    - /app/node_modules  # Preserves container's node_modules\n

Same reload behavior as local: - API: tsx watch restarts on .ts changes - Admin: Vite HMR updates browser

Performance note: - macOS/Windows: Volume mounts slightly slower than Linux - For intensive development, consider running locally instead

"},{"location":"v2/development/local-setup/#debugging","title":"Debugging","text":""},{"location":"v2/development/local-setup/#api-debugging-vscode","title":"API Debugging (VSCode)","text":"
  1. Open VSCode
  2. Open Run and Debug panel (Cmd+Shift+D / Ctrl+Shift+D)
  3. Select \"Debug API\" configuration
  4. Press F5 to start debugging
  5. Set breakpoints by clicking line numbers
  6. Trigger API endpoint to hit breakpoint

Debugging features: - Step through code (F10, F11) - Inspect variables - Evaluate expressions in Debug Console - Call stack navigation

"},{"location":"v2/development/local-setup/#frontend-debugging-chrome-devtools","title":"Frontend Debugging (Chrome DevTools)","text":"
  1. Open Admin in Chrome: http://localhost:3000
  2. Open DevTools (F12 / Cmd+Option+I)
  3. Go to Sources tab
  4. Find your component in file tree (webpack://./src/)
  5. Set breakpoints by clicking line numbers
  6. Interact with UI to trigger breakpoint

React DevTools: - Install React DevTools browser extension - Inspect component tree - View/edit props and state - Profile component renders

"},{"location":"v2/development/local-setup/#zustand-devtools","title":"Zustand DevTools","text":"

Enable Redux DevTools for Zustand stores:

// Already configured in auth.store.ts and canvass.store.ts\nimport { devtools } from 'zustand/middleware';\n\nexport const useAuthStore = create<AuthState>()(\n  devtools(\n    (set, get) => ({\n      // ... store implementation\n    }),\n    { name: 'AuthStore' }\n  )\n);\n

Usage: 1. Install Redux DevTools browser extension 2. Open extension 3. Select \"AuthStore\" or \"CanvassStore\" 4. See action history and state changes

"},{"location":"v2/development/local-setup/#common-workflows","title":"Common Workflows","text":""},{"location":"v2/development/local-setup/#starting-fresh-development-day","title":"Starting Fresh Development Day","text":"
# 1. Pull latest changes\ngit pull origin v2\n\n# 2. Check for dependency updates\ncd api && npm install && cd ..\ncd admin && npm install && cd ..\n\n# 3. Apply any new migrations\ncd api && npx prisma migrate deploy && cd ..\n\n# 4. Start services\ndocker compose up -d v2-postgres redis mailhog\n# Either:\ndocker compose up -d api admin  # Docker approach\n# Or:\ncd api && npm run dev  # Terminal 1 (local approach)\ncd admin && npm run dev  # Terminal 2 (local approach)\n\n# 5. Open browser\nopen http://localhost:3000\n
"},{"location":"v2/development/local-setup/#feature-development-workflow","title":"Feature Development Workflow","text":"
# 1. Create feature branch\ngit checkout -b feature/my-new-feature\n\n# 2. Start development servers (see above)\n\n# 3. Make changes\n# - Edit code\n# - Test in browser\n# - Check API responses\n\n# 4. Type-check\ncd api && npx tsc --noEmit && cd ..\ncd admin && npx tsc --noEmit && cd ..\n\n# 5. Run tests (when available)\n# cd api && npm test && cd ..\n# cd admin && npm test && cd ..\n\n# 6. Commit changes\ngit add .\ngit commit -m \"feat: add new feature\"\n\n# 7. Push and create PR\ngit push origin feature/my-new-feature\n# Open PR on GitHub/GitLab\n
"},{"location":"v2/development/local-setup/#database-schema-changes","title":"Database Schema Changes","text":"
# 1. Edit Prisma schema\ncd api\nvi prisma/schema.prisma  # Add/modify models\n\n# 2. Create migration\nnpx prisma migrate dev --name add_new_field\n\n# 3. Migration auto-applies to dev database\n# Check generated SQL in prisma/migrations/\n\n# 4. Update seed if needed\nvi prisma/seed.ts\n\n# 5. Test migration on clean database\nnpx prisma migrate reset  # WARNING: Deletes data\n# Re-run migrations + seed\n\n# 6. Commit migration files\ngit add prisma/migrations/ prisma/schema.prisma\ngit commit -m \"feat(db): add new field to User model\"\n
"},{"location":"v2/development/local-setup/#bug-fixing-workflow","title":"Bug Fixing Workflow","text":"
# 1. Reproduce bug locally\n# - Follow steps from bug report\n# - Check browser console\n# - Check API logs (docker compose logs -f api)\n\n# 2. Add logging to isolate issue\n# api/src/modules/foo/foo.service.ts\nlogger.error('Bug context', { data });\n\n# 3. Set breakpoints (VSCode debug)\n# - Run \"Debug API\" configuration\n# - Trigger bug\n# - Step through code\n\n# 4. Fix bug\n# - Make code changes\n# - Hot reload picks up changes\n# - Test fix\n\n# 5. Verify fix\n# - Re-test original bug steps\n# - Check related functionality\n# - Type-check: npx tsc --noEmit\n\n# 6. Commit fix\ngit add .\ngit commit -m \"fix: resolve issue with user login\"\n
"},{"location":"v2/development/local-setup/#switching-between-docker-and-local","title":"Switching Between Docker and Local","text":"

From Docker to Local:

# 1. Stop Docker services\ndocker compose stop api admin\n\n# 2. Keep databases running\ndocker compose ps v2-postgres redis mailhog\n# Should show running\n\n# 3. Start local dev servers\ncd api && npm run dev  # Terminal 1\ncd admin && npm run dev  # Terminal 2\n

From Local to Docker:

# 1. Stop local dev servers\n# Press Ctrl+C in both terminals\n\n# 2. Start Docker services\ndocker compose up -d api admin\n\n# 3. Watch logs\ndocker compose logs -f api admin\n
"},{"location":"v2/development/local-setup/#next-steps","title":"Next Steps","text":"

After completing local setup:

  1. Read Development Guides:
  2. NPM Commands Reference - All package.json scripts
  3. Docker Workflow - Advanced Docker development
  4. Database Migrations - Schema change workflow

  5. Understand Architecture:

  6. API Architecture - Backend organization
  7. Frontend Architecture - React app structure
  8. Database Schema - Data models

  9. Learn Code Patterns:

  10. TypeScript Guide - TypeScript best practices
  11. Code Style Guide - Coding standards
  12. Testing Guide - Test writing

  13. Start Contributing:

  14. Git Workflow - Branching and commits
  15. Contributing Guide - Contribution process
  16. V2 Development Plan - Roadmap and phases
"},{"location":"v2/development/local-setup/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/development/local-setup/#getting-help","title":"Getting Help","text":"

Documentation: - This guide for setup issues - Troubleshooting for common problems - FAQ for quick answers

Community: - GitHub Issues for bug reports - GitHub Discussions for questions - Project README for contact info

Logs: - API logs: docker compose logs -f api - Admin logs: docker compose logs -f admin - Database logs: docker compose logs -f v2-postgres

"},{"location":"v2/development/local-setup/#summary","title":"Summary","text":"

You now have: - \u2705 Prerequisites installed (Node.js, Docker, Git) - \u2705 Repository cloned and on v2 branch - \u2705 Environment configured (.env file) - \u2705 Database initialized (migrations + seed) - \u2705 Services running (API + Admin + databases) - \u2705 IDE configured (VSCode with extensions) - \u2705 Admin GUI accessible (http://localhost:3000)

Test your setup: 1. Login to Admin GUI (admin@example.com / Admin123!) 2. Navigate to Users page (/app/users) 3. See yourself in the users table 4. Check MailHog (http://localhost:8025) for welcome email

Ready to develop! Choose a task from V2_PLAN.md Phase 15 or create a feature branch.

"},{"location":"v2/development/migrations/","title":"Database Migrations Guide","text":"

Complete guide to managing database schema changes in Changemaker Lite V2 using Prisma Migrate and Drizzle Kit.

"},{"location":"v2/development/migrations/#overview","title":"Overview","text":"

Changemaker Lite V2 uses two ORMs for different parts of the application:

This guide covers both workflows.

"},{"location":"v2/development/migrations/#prisma-migrations-main-api","title":"Prisma Migrations (Main API)","text":""},{"location":"v2/development/migrations/#migration-workflow-overview","title":"Migration Workflow Overview","text":"
1. Edit schema.prisma\n        \u2193\n2. Create migration (npx prisma migrate dev)\n        \u2193\n3. Review generated SQL\n        \u2193\n4. Test migration locally\n        \u2193\n5. Commit migration files\n        \u2193\n6. Deploy to production (npx prisma migrate deploy)\n
"},{"location":"v2/development/migrations/#understanding-prisma-migrate","title":"Understanding Prisma Migrate","text":"

Prisma Migrate: - Tracks schema changes as SQL migration files - Stores migration history in _prisma_migrations table - Ensures schema consistency across environments - Supports rollback via version control

Migration Files: - Located in api/prisma/migrations/ - Named with timestamp: 20260213123456_description/ - Contains migration.sql (SQL commands)

Migration States: - Pending: Not yet applied - Applied: Successfully executed - Failed: Execution error (requires manual fix)

"},{"location":"v2/development/migrations/#creating-migrations","title":"Creating Migrations","text":""},{"location":"v2/development/migrations/#step-1-edit-prisma-schema","title":"Step 1: Edit Prisma Schema","text":"

Edit api/prisma/schema.prisma:

// Before\nmodel User {\n  id        Int      @id @default(autoincrement())\n  email     String   @unique\n  password  String\n  role      Role     @default(USER)\n  createdAt DateTime @default(now())\n}\n\n// After (add name field)\nmodel User {\n  id        Int      @id @default(autoincrement())\n  email     String   @unique\n  password  String\n  name      String?  // New field (nullable)\n  role      Role     @default(USER)\n  createdAt DateTime @default(now())\n}\n
"},{"location":"v2/development/migrations/#step-2-validate-schema","title":"Step 2: Validate Schema","text":"
cd api\nnpx prisma validate\n

Expected output:

Environment variables loaded from .env\nPrisma schema loaded from prisma/schema.prisma\nThe schema is valid \u2714\n

If errors:

Error validating model \"User\": Field \"foo\" references unknown model \"Bar\"\n

Fix errors before proceeding.

"},{"location":"v2/development/migrations/#step-3-create-migration","title":"Step 3: Create Migration","text":"
cd api\nnpx prisma migrate dev --name add_user_name\n

What happens: 1. Prisma detects schema changes 2. Generates SQL migration file 3. Prompts for migration name (or uses --name argument) 4. Applies migration to development database 5. Regenerates Prisma Client

Expected output:

Environment variables loaded from .env\nPrisma schema loaded from prisma/schema.prisma\nDatasource \"db\": PostgreSQL database \"changemaker_v2_db\"\n\nApplying migration `20260213123456_add_user_name`\nRunning seed command `tsx prisma/seed.ts` ...\n\n\u2714 Generated Prisma Client to ./node_modules/@prisma/client\n

Migration file created:

api/prisma/migrations/\n\u2514\u2500\u2500 20260213123456_add_user_name/\n    \u2514\u2500\u2500 migration.sql\n

"},{"location":"v2/development/migrations/#step-4-review-generated-sql","title":"Step 4: Review Generated SQL","text":"
cd api\ncat prisma/migrations/20260213123456_add_user_name/migration.sql\n

Example SQL:

-- AlterTable\nALTER TABLE \"users\" ADD COLUMN \"name\" TEXT;\n

Verify SQL is correct: - Check table names match expectations - Ensure data types are correct - Look for unexpected DROP commands

"},{"location":"v2/development/migrations/#step-5-test-migration","title":"Step 5: Test Migration","text":"

Migration already applied to development DB. Verify:

# Check schema with Prisma Studio\ncd api\nnpx prisma studio\n

Or query directly:

# PostgreSQL shell\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db\n\n# Describe users table\nchangemaker_v2_db=# \\d users;\n

Expected output:

Column    |  Type   | Nullable | Default\n----------+---------+----------+---------\nid        | integer | not null | nextval(...)\nemail     | text    | not null |\npassword  | text    | not null |\nname      | text    |          |  <-- New field\nrole      | text    | not null | 'USER'\ncreated_at| timestamp| not null | now()\n

"},{"location":"v2/development/migrations/#step-6-commit-migration","title":"Step 6: Commit Migration","text":"
git add prisma/migrations/20260213123456_add_user_name/\ngit add prisma/schema.prisma\ngit commit -m \"feat(db): add name field to User model\"\n

Always commit: - Migration directory (prisma/migrations/*/) - Updated schema.prisma

"},{"location":"v2/development/migrations/#applying-migrations-production","title":"Applying Migrations (Production)","text":""},{"location":"v2/development/migrations/#in-production-environment","title":"In Production Environment","text":"
cd api\nnpx prisma migrate deploy\n

What it does: - Checks _prisma_migrations table for applied migrations - Applies only pending migrations - Does NOT create new migrations - Safe for production

Expected output:

Environment variables loaded from .env\nPrisma schema loaded from prisma/schema.prisma\nDatasource \"db\": PostgreSQL database \"changemaker_v2_prod_db\"\n\n2 migrations found in prisma/migrations\n\nApplying migration `20260213123456_add_user_name`\nApplying migration `20260214000000_add_user_avatar`\n\nAll migrations have been successfully applied.\n

"},{"location":"v2/development/migrations/#in-docker","title":"In Docker","text":"
# Apply migrations in Docker container\ndocker compose exec api npx prisma migrate deploy\n\n# Or during container startup (Dockerfile)\nCMD npx prisma migrate deploy && npm start\n
"},{"location":"v2/development/migrations/#cicd-deployment","title":"CI/CD Deployment","text":"
# GitHub Actions example\n- name: Run migrations\n  run: |\n    cd api\n    npx prisma migrate deploy\n
"},{"location":"v2/development/migrations/#migration-best-practices","title":"Migration Best Practices","text":""},{"location":"v2/development/migrations/#1-incremental-changes","title":"1. Incremental Changes","text":"

Make small, focused migrations:

Good:

# Separate migrations\nnpx prisma migrate dev --name add_user_name\nnpx prisma migrate dev --name add_user_avatar\nnpx prisma migrate dev --name add_user_bio\n

Bad:

# One huge migration\nnpx prisma migrate dev --name update_user_model\n# (adds 10 fields, 3 relations, 5 indexes)\n

"},{"location":"v2/development/migrations/#2-descriptive-names","title":"2. Descriptive Names","text":"

Use clear migration names:

Good:

npx prisma migrate dev --name add_user_name\nnpx prisma migrate dev --name make_email_unique\nnpx prisma migrate dev --name create_posts_table\nnpx prisma migrate dev --name add_user_posts_relation\n

Bad:

npx prisma migrate dev --name update\nnpx prisma migrate dev --name fix\nnpx prisma migrate dev --name changes\n

"},{"location":"v2/development/migrations/#3-review-sql-before-committing","title":"3. Review SQL Before Committing","text":"

Always review generated SQL:

cat prisma/migrations/*/migration.sql\n

Watch for: - Unexpected DROP TABLE or DROP COLUMN - Missing NOT NULL constraints - Incorrect data types - Missing indexes on foreign keys

"},{"location":"v2/development/migrations/#4-backup-before-migration-production","title":"4. Backup Before Migration (Production)","text":"
# Backup database before deploy\ndocker compose exec v2-postgres pg_dump -U changemaker_v2 changemaker_v2_db > backup-$(date +%Y%m%d).sql\n\n# Apply migration\nnpx prisma migrate deploy\n\n# If migration fails, restore:\ncat backup-20260213.sql | docker compose exec -T v2-postgres psql -U changemaker_v2 changemaker_v2_db\n
"},{"location":"v2/development/migrations/#5-test-on-staging-first","title":"5. Test on Staging First","text":"

Never deploy migrations directly to production:

1. Create migration in development\n2. Test locally\n3. Commit to version control\n4. Deploy to staging environment\n5. Test on staging\n6. Deploy to production\n
"},{"location":"v2/development/migrations/#common-migration-scenarios","title":"Common Migration Scenarios","text":""},{"location":"v2/development/migrations/#add-new-field","title":"Add New Field","text":"
// schema.prisma\nmodel User {\n  id    Int    @id @default(autoincrement())\n  email String @unique\n  name  String? // New nullable field\n}\n
npx prisma migrate dev --name add_user_name\n

Generated SQL:

ALTER TABLE \"users\" ADD COLUMN \"name\" TEXT;\n

"},{"location":"v2/development/migrations/#add-required-field-with-default","title":"Add Required Field (with Default)","text":"
model User {\n  id        Int      @id @default(autoincrement())\n  email     String   @unique\n  createdAt DateTime @default(now()) // New required field with default\n}\n
npx prisma migrate dev --name add_created_at\n

Generated SQL:

ALTER TABLE \"users\" ADD COLUMN \"created_at\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;\n

"},{"location":"v2/development/migrations/#add-new-table","title":"Add New Table","text":"
model Post {\n  id        Int      @id @default(autoincrement())\n  title     String\n  content   String?\n  published Boolean  @default(false)\n  authorId  Int\n  author    User     @relation(fields: [authorId], references: [id])\n  createdAt DateTime @default(now())\n}\n\nmodel User {\n  id    Int    @id @default(autoincrement())\n  email String @unique\n  posts Post[]\n}\n
npx prisma migrate dev --name create_posts_table\n

Generated SQL:

CREATE TABLE \"posts\" (\n    \"id\" SERIAL NOT NULL,\n    \"title\" TEXT NOT NULL,\n    \"content\" TEXT,\n    \"published\" BOOLEAN NOT NULL DEFAULT false,\n    \"author_id\" INTEGER NOT NULL,\n    \"created_at\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"posts_pkey\" PRIMARY KEY (\"id\")\n);\n\nCREATE INDEX \"posts_author_id_idx\" ON \"posts\"(\"author_id\");\n\nALTER TABLE \"posts\" ADD CONSTRAINT \"posts_author_id_fkey\"\n    FOREIGN KEY (\"author_id\") REFERENCES \"users\"(\"id\")\n    ON DELETE RESTRICT ON UPDATE CASCADE;\n

"},{"location":"v2/development/migrations/#add-relation","title":"Add Relation","text":"
model Campaign {\n  id           Int    @id @default(autoincrement())\n  title        String\n  createdByUserId Int // New foreign key\n  createdBy    User   @relation(fields: [createdByUserId], references: [id])\n}\n\nmodel User {\n  id        Int        @id @default(autoincrement())\n  email     String     @unique\n  campaigns Campaign[]\n}\n
npx prisma migrate dev --name add_campaign_user_relation\n

Generated SQL:

ALTER TABLE \"campaigns\" ADD COLUMN \"created_by_user_id\" INTEGER NOT NULL;\n\nCREATE INDEX \"campaigns_created_by_user_id_idx\" ON \"campaigns\"(\"created_by_user_id\");\n\nALTER TABLE \"campaigns\" ADD CONSTRAINT \"campaigns_created_by_user_id_fkey\"\n    FOREIGN KEY (\"created_by_user_id\") REFERENCES \"users\"(\"id\")\n    ON DELETE RESTRICT ON UPDATE CASCADE;\n

"},{"location":"v2/development/migrations/#change-field-type","title":"Change Field Type","text":"
// Before\nmodel User {\n  age Int\n}\n\n// After\nmodel User {\n  age String // Changed from Int to String\n}\n
npx prisma migrate dev --name change_user_age_to_string\n

Generated SQL:

ALTER TABLE \"users\" ALTER COLUMN \"age\" SET DATA TYPE TEXT;\n

Warning: This may fail if data cannot be cast. Consider data migration first.

"},{"location":"v2/development/migrations/#add-unique-constraint","title":"Add Unique Constraint","text":"
model User {\n  email String @unique // Add unique constraint\n}\n
npx prisma migrate dev --name make_email_unique\n

Generated SQL:

CREATE UNIQUE INDEX \"users_email_key\" ON \"users\"(\"email\");\n

"},{"location":"v2/development/migrations/#add-index","title":"Add Index","text":"
model User {\n  email String\n\n  @@index([email]) // Add index\n}\n
npx prisma migrate dev --name add_email_index\n

Generated SQL:

CREATE INDEX \"users_email_idx\" ON \"users\"(\"email\");\n

"},{"location":"v2/development/migrations/#migration-history-and-status","title":"Migration History and Status","text":""},{"location":"v2/development/migrations/#check-migration-status","title":"Check Migration Status","text":"
cd api\nnpx prisma migrate status\n

Expected output:

Environment variables loaded from .env\nPrisma schema loaded from prisma/schema.prisma\nDatasource \"db\": PostgreSQL database \"changemaker_v2_db\"\n\nDatabase schema is up to date!\n\nFollowing migrations have been applied:\n\n20260101000000_init\n20260105000000_add_campaigns\n20260110000000_add_locations\n20260213123456_add_user_name\n

"},{"location":"v2/development/migrations/#view-migration-history","title":"View Migration History","text":"
# List migration files\nls -la api/prisma/migrations/\n\n# View specific migration\ncat api/prisma/migrations/20260213123456_add_user_name/migration.sql\n
"},{"location":"v2/development/migrations/#check-database-migration-table","title":"Check Database Migration Table","text":"
docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"SELECT * FROM _prisma_migrations;\"\n

Output:

id | checksum | finished_at | migration_name | logs\n---+----------+-------------+----------------+-----\n1  | abc123   | 2026-01-01  | 20260101000000_init | NULL\n2  | def456   | 2026-01-05  | 20260105000000_add_campaigns | NULL\n

"},{"location":"v2/development/migrations/#rollback-strategies","title":"Rollback Strategies","text":"

Prisma Migrate does NOT have automatic rollback. Use these strategies:

"},{"location":"v2/development/migrations/#1-version-control-rollback","title":"1. Version Control Rollback","text":"
# Revert schema changes\ngit revert <commit-hash>\n\n# Create new migration to undo changes\nnpx prisma migrate dev --name revert_user_name\n\n# This creates a new migration that undoes the previous one\n
"},{"location":"v2/development/migrations/#2-manual-rollback-migration","title":"2. Manual Rollback Migration","text":"

Create a new migration to reverse changes:

// If you added a field, remove it\nmodel User {\n  id    Int    @id @default(autoincrement())\n  email String @unique\n  // name  String? // Remove this\n}\n
npx prisma migrate dev --name remove_user_name\n

Generated SQL:

ALTER TABLE \"users\" DROP COLUMN \"name\";\n

"},{"location":"v2/development/migrations/#3-database-restore-last-resort","title":"3. Database Restore (Last Resort)","text":"
# Restore from backup\ncat backup-20260213.sql | docker compose exec -T v2-postgres psql -U changemaker_v2 changemaker_v2_db\n\n# Mark migrations as rolled back\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"\n  DELETE FROM _prisma_migrations\n  WHERE migration_name = '20260213123456_add_user_name';\n\"\n
"},{"location":"v2/development/migrations/#4-reset-development-database","title":"4. Reset Development Database","text":"

WARNING: Deletes all data!

cd api\nnpx prisma migrate reset\n

This: 1. Drops all tables 2. Re-applies all migrations from scratch 3. Runs seed script

"},{"location":"v2/development/migrations/#handling-migration-conflicts","title":"Handling Migration Conflicts","text":""},{"location":"v2/development/migrations/#schema-drift","title":"Schema Drift","text":"

Problem: Database schema doesn't match Prisma schema.

Symptoms:

Error: Database schema is not in sync with the migration history\n

Solution:

# Check what's different\nnpx prisma migrate diff \\\n  --from-schema-datamodel prisma/schema.prisma \\\n  --to-schema-datasource prisma/schema.prisma\n\n# Create migration to fix drift\nnpx prisma migrate dev --name fix_schema_drift\n
"},{"location":"v2/development/migrations/#failed-migration","title":"Failed Migration","text":"

Problem: Migration fails during apply.

Symptoms:

Error: Migration failed with error:\n  ALTER TABLE \"users\" ADD COLUMN \"age\" INTEGER NOT NULL;\n  ERROR: column \"age\" contains null values\n

Solution:

# 1. Mark migration as rolled back\nnpx prisma migrate resolve --rolled-back 20260213123456_add_user_age\n\n# 2. Fix migration SQL manually\nvi prisma/migrations/20260213123456_add_user_age/migration.sql\n\n# Change to:\nALTER TABLE \"users\" ADD COLUMN \"age\" INTEGER; -- Make nullable first\nUPDATE \"users\" SET \"age\" = 0 WHERE \"age\" IS NULL; -- Set default\nALTER TABLE \"users\" ALTER COLUMN \"age\" SET NOT NULL; -- Then make required\n\n# 3. Apply migration again\nnpx prisma migrate deploy\n
"},{"location":"v2/development/migrations/#conflicting-migrations-team-environment","title":"Conflicting Migrations (Team Environment)","text":"

Problem: Two developers create migrations simultaneously.

Solution:

# 1. Pull latest changes\ngit pull origin v2\n\n# 2. Prisma detects conflict\nnpx prisma migrate dev\n\n# 3. Resolve by creating merge migration\n# Prisma will prompt you to create a migration that includes both changes\n
"},{"location":"v2/development/migrations/#data-migrations","title":"Data Migrations","text":"

Prisma Migrate handles schema changes, not data changes. For data transformations:

"},{"location":"v2/development/migrations/#option-1-custom-sql-in-migration","title":"Option 1: Custom SQL in Migration","text":"

Edit generated migration file:

-- Add column (Prisma-generated)\nALTER TABLE \"users\" ADD COLUMN \"full_name\" TEXT;\n\n-- Populate from existing data (manual addition)\nUPDATE \"users\" SET \"full_name\" = \"first_name\" || ' ' || \"last_name\";\n\n-- Remove old columns (Prisma-generated)\nALTER TABLE \"users\" DROP COLUMN \"first_name\";\nALTER TABLE \"users\" DROP COLUMN \"last_name\";\n
"},{"location":"v2/development/migrations/#option-2-separate-data-migration-script","title":"Option 2: Separate Data Migration Script","text":"
// api/prisma/data-migrations/20260213-populate-full-name.ts\nimport { PrismaClient } from '@prisma/client';\n\nconst prisma = new PrismaClient();\n\nasync function main() {\n  const users = await prisma.user.findMany();\n\n  for (const user of users) {\n    await prisma.user.update({\n      where: { id: user.id },\n      data: {\n        fullName: `${user.firstName} ${user.lastName}`\n      }\n    });\n  }\n\n  console.log(`Updated ${users.length} users`);\n}\n\nmain()\n  .catch(console.error)\n  .finally(() => prisma.$disconnect());\n

Run after migration:

npx tsx prisma/data-migrations/20260213-populate-full-name.ts\n
"},{"location":"v2/development/migrations/#drizzle-push-media-api","title":"Drizzle Push (Media API)","text":""},{"location":"v2/development/migrations/#drizzle-overview","title":"Drizzle Overview","text":"

Drizzle Kit Push: - Syncs schema directly to database - No migration files generated - Fast iteration for prototyping - Used only for Media API tables

Schema Location: - api/src/modules/media/db/schema.ts

When to Use: - Rapid prototyping - Development only - Media API tables (videos, jobs, reactions)

When NOT to Use: - Production deployments - Main API tables (use Prisma) - When migration history is needed

"},{"location":"v2/development/migrations/#drizzle-push-workflow","title":"Drizzle Push Workflow","text":""},{"location":"v2/development/migrations/#step-1-edit-schema","title":"Step 1: Edit Schema","text":"

Edit api/src/modules/media/db/schema.ts:

// Before\nexport const videos = pgTable('videos', {\n  id: serial('id').primaryKey(),\n  filename: text('filename').notNull(),\n  title: text('title'),\n  duration: integer('duration'),\n  createdAt: timestamp('created_at').defaultNow().notNull(),\n});\n\n// After (add description field)\nexport const videos = pgTable('videos', {\n  id: serial('id').primaryKey(),\n  filename: text('filename').notNull(),\n  title: text('title'),\n  description: text('description'), // New field\n  duration: integer('duration'),\n  createdAt: timestamp('created_at').defaultNow().notNull(),\n});\n
"},{"location":"v2/development/migrations/#step-2-push-schema","title":"Step 2: Push Schema","text":"
cd api\nnpm run drizzle:push\n

Or directly:

cd api\nnpx drizzle-kit push\n

What happens: 1. Drizzle compares schema to database 2. Generates SQL for changes 3. Applies changes immediately 4. No migration files created

Expected output:

Reading config from drizzle.config.ts\nUsing 'pg' driver for database querying\n\nPulling schema from database...\n[\u2713] Schema pulled successfully\n\nComparing schemas...\n[!] Changes detected:\n  - ALTER TABLE \"videos\" ADD COLUMN \"description\" TEXT;\n\nDo you want to execute these changes? [y/N]: y\n\nApplying changes...\n[\u2713] Schema pushed successfully\n

"},{"location":"v2/development/migrations/#step-3-verify-changes","title":"Step 3: Verify Changes","text":"
# Check with Drizzle Studio\ncd api\nnpx drizzle-kit studio\n

Or query directly:

docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"\\d videos\"\n
"},{"location":"v2/development/migrations/#drizzle-best-practices","title":"Drizzle Best Practices","text":""},{"location":"v2/development/migrations/#1-development-only","title":"1. Development Only","text":"

Use Drizzle Push only in development:

Good:

# Development\nnpm run drizzle:push\n

Bad:

# Production (use Prisma migrate for production schema changes)\nnpm run drizzle:push\n

"},{"location":"v2/development/migrations/#2-backup-before-push","title":"2. Backup Before Push","text":"

Always backup before pushing schema:

# Backup database\ndocker compose exec v2-postgres pg_dump -U changemaker_v2 changemaker_v2_db > backup.sql\n\n# Push schema\nnpm run drizzle:push\n\n# If something breaks, restore:\ncat backup.sql | docker compose exec -T v2-postgres psql -U changemaker_v2 changemaker_v2_db\n
"},{"location":"v2/development/migrations/#3-test-changes-locally","title":"3. Test Changes Locally","text":"

Never push untested schema changes:

# 1. Edit schema\nvi src/modules/media/db/schema.ts\n\n# 2. Push to dev database\nnpm run drizzle:push\n\n# 3. Test with Drizzle Studio\nnpm run drizzle:studio\n\n# 4. Test API endpoints\ncurl http://localhost:4100/api/media/videos\n
"},{"location":"v2/development/migrations/#drizzle-vs-prisma","title":"Drizzle vs Prisma","text":"Feature Prisma Migrate Drizzle Push Migration files \u2705 Yes \u274c No Migration history \u2705 Tracked \u274c Not tracked Rollback \u2705 Via version control \u274c Manual only Production use \u2705 Recommended \u26a0\ufe0f Not recommended Prototyping \u26a0\ufe0f Slower \u2705 Faster Use case Main API tables Media API tables"},{"location":"v2/development/migrations/#seeding-after-migration","title":"Seeding After Migration","text":""},{"location":"v2/development/migrations/#running-seed-script","title":"Running Seed Script","text":"

After migrations, seed database:

cd api\nnpx prisma db seed\n

What it does: - Runs prisma/seed.ts - Creates admin user - Creates default settings - Creates sample blocks

Expected output:

Running seed command `tsx prisma/seed.ts` ...\nSeeding database...\nCreated default settings\nCreated admin user: admin@example.com\nCreated 10 sample blocks\nSeed completed successfully\n

"},{"location":"v2/development/migrations/#custom-seed-data","title":"Custom Seed Data","text":"

Edit api/prisma/seed.ts:

import { PrismaClient } from '@prisma/client';\n\nconst prisma = new PrismaClient();\n\nasync function main() {\n  // Create admin user\n  await prisma.user.upsert({\n    where: { email: 'admin@example.com' },\n    update: {},\n    create: {\n      email: 'admin@example.com',\n      password: await hashPassword('Admin123!'),\n      role: 'SUPER_ADMIN',\n      name: 'Admin User'\n    }\n  });\n\n  // Create sample campaign\n  await prisma.campaign.create({\n    data: {\n      title: 'Sample Campaign',\n      description: 'This is a sample campaign',\n      active: true,\n      createdByUserId: 1\n    }\n  });\n\n  console.log('Seed completed');\n}\n\nmain()\n  .catch(console.error)\n  .finally(() => prisma.$disconnect());\n
"},{"location":"v2/development/migrations/#cicd-integration","title":"CI/CD Integration","text":""},{"location":"v2/development/migrations/#github-actions-example","title":"GitHub Actions Example","text":"
name: Deploy\n\non:\n  push:\n    branches: [main]\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v3\n        with:\n          node-version: '20'\n\n      - name: Install dependencies\n        working-directory: ./api\n        run: npm ci\n\n      - name: Run migrations\n        working-directory: ./api\n        env:\n          DATABASE_URL: ${{ secrets.DATABASE_URL }}\n        run: npx prisma migrate deploy\n\n      - name: Seed database\n        working-directory: ./api\n        env:\n          DATABASE_URL: ${{ secrets.DATABASE_URL }}\n        run: npx prisma db seed\n
"},{"location":"v2/development/migrations/#docker-deployment","title":"Docker Deployment","text":"
# api/Dockerfile\nFROM node:20-alpine\n\nWORKDIR /app\n\nCOPY package*.json ./\nRUN npm ci --production\n\nCOPY . .\n\n# Generate Prisma Client\nRUN npx prisma generate\n\n# Run migrations on startup\nCMD npx prisma migrate deploy && npm start\n
"},{"location":"v2/development/migrations/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/development/migrations/#migration-fails-with-column-already-exists","title":"Migration Fails with \"Column Already Exists\"","text":"

Problem:

Error: column \"name\" of relation \"users\" already exists\n

Solution:

# Mark migration as applied\nnpx prisma migrate resolve --applied 20260213123456_add_user_name\n\n# Or drop column manually and re-run\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"ALTER TABLE users DROP COLUMN name;\"\nnpx prisma migrate deploy\n
"},{"location":"v2/development/migrations/#migration-fails-with-relation-does-not-exist","title":"Migration Fails with \"Relation Does Not Exist\"","text":"

Problem:

Error: relation \"posts\" does not exist\n

Solution:

# Check migration history\nnpx prisma migrate status\n\n# Apply missing migrations\nnpx prisma migrate deploy\n\n# Or reset (development only)\nnpx prisma migrate reset\n
"},{"location":"v2/development/migrations/#schema-out-of-sync","title":"Schema Out of Sync","text":"

Problem:

Error: Database schema is not in sync\n

Solution:

# Generate migration to fix drift\nnpx prisma migrate dev --name fix_drift\n\n# Or in production, create explicit migration\nnpx prisma migrate diff \\\n  --from-schema-datamodel prisma/schema.prisma \\\n  --to-schema-datasource prisma/schema.prisma \\\n  --script > fix-drift.sql\n\n# Review fix-drift.sql and apply manually\n
"},{"location":"v2/development/migrations/#drizzle-push-fails","title":"Drizzle Push Fails","text":"

Problem:

Error: Could not push schema\n

Solution:

# Check Drizzle config\ncat api/drizzle.config.ts\n\n# Verify DATABASE_URL\necho $DATABASE_URL\n\n# Test database connection\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"SELECT 1\"\n\n# Clear Drizzle cache and retry\nrm -rf api/.drizzle\nnpm run drizzle:push\n
"},{"location":"v2/development/migrations/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/development/migrations/#summary","title":"Summary","text":"

You now know: - \u2705 How Prisma Migrate tracks schema changes - \u2705 How to create and apply migrations - \u2705 Common migration scenarios (add field, table, relation) - \u2705 Migration best practices - \u2705 How to handle migration conflicts - \u2705 How to perform data migrations - \u2705 How Drizzle Push works for Media API - \u2705 When to use Prisma vs Drizzle - \u2705 How to seed database after migrations - \u2705 How to integrate migrations in CI/CD

Quick Reference:

# Prisma: Create migration\nnpx prisma migrate dev --name description\n\n# Prisma: Apply migrations (production)\nnpx prisma migrate deploy\n\n# Prisma: Check status\nnpx prisma migrate status\n\n# Drizzle: Push schema (dev only)\nnpx drizzle-kit push\n\n# Seed database\nnpx prisma db seed\n\n# Reset (dev only, DELETES DATA)\nnpx prisma migrate reset\n

"},{"location":"v2/development/npm-commands/","title":"NPM Commands Reference","text":"

Complete reference for all npm scripts in Changemaker Lite V2.

"},{"location":"v2/development/npm-commands/#overview","title":"Overview","text":"

Changemaker Lite V2 uses npm scripts for development, building, testing, and database management. Scripts are defined in package.json files in two main directories:

This guide documents all available scripts, their usage, and common combinations.

"},{"location":"v2/development/npm-commands/#api-scripts","title":"API Scripts","text":"

Location: api/package.json

"},{"location":"v2/development/npm-commands/#development-scripts","title":"Development Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-dev","title":"npm run dev","text":"

Starts the Express API server in development mode with hot reload.

cd api\nnpm run dev\n

What it does: - Runs tsx watch src/server.ts - Auto-restarts on file changes (.ts files) - Loads environment from .env - Runs on port API_PORT (default: 4000)

Output:

Server running on port 4000\nDatabase connected\nRedis connected\nBullMQ worker started\n

Use when: - Developing API endpoints - Testing backend changes - Debugging server code

"},{"location":"v2/development/npm-commands/#npm-run-devmedia","title":"npm run dev:media","text":"

Starts the Fastify Media API server in development mode.

cd api\nnpm run dev:media\n

What it does: - Runs tsx watch src/media-server.ts - Auto-restarts on file changes - Runs on port MEDIA_API_PORT (default: 4100)

Output:

Media API server running on port 4100\nDatabase connected\n

Use when: - Developing media features (video upload, reactions) - Testing Media API endpoints - Working on FFprobe integration

"},{"location":"v2/development/npm-commands/#build-scripts","title":"Build Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-build","title":"npm run build","text":"

Compiles TypeScript to JavaScript for production.

cd api\nnpm run build\n

What it does: - Runs tsc --build - Outputs to dist/ directory - Type-checks all code - Fails on type errors

Output:

dist/\n\u251c\u2500\u2500 server.js\n\u251c\u2500\u2500 media-server.js\n\u2514\u2500\u2500 modules/\n    \u251c\u2500\u2500 auth/\n    \u251c\u2500\u2500 users/\n    \u2514\u2500\u2500 ...\n

Use when: - Preparing for production deployment - Verifying build succeeds - Creating Docker images

"},{"location":"v2/development/npm-commands/#npm-run-clean","title":"npm run clean","text":"

Removes compiled JavaScript and build artifacts.

cd api\nnpm run clean\n

What it does: - Deletes dist/ directory - Removes *.tsbuildinfo files

Use when: - Starting fresh build - Fixing build cache issues - Cleaning up after development

"},{"location":"v2/development/npm-commands/#production-scripts","title":"Production Scripts","text":""},{"location":"v2/development/npm-commands/#npm-start","title":"npm start","text":"

Runs the compiled API server (production mode).

cd api\nnpm start\n

What it does: - Runs node dist/server.js - Requires npm run build first - Uses production environment (NODE_ENV=production)

Output:

Server running on port 4000\nDatabase connected\nRedis connected\n

Use when: - Running in production (Docker) - Testing production build locally

"},{"location":"v2/development/npm-commands/#npm-run-startmedia","title":"npm run start:media","text":"

Runs the compiled Media API server (production mode).

cd api\nnpm run start:media\n

What it does: - Runs node dist/media-server.js - Requires npm run build first

Use when: - Running Media API in production - Testing production Media API

"},{"location":"v2/development/npm-commands/#code-quality-scripts","title":"Code Quality Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-type-check","title":"npm run type-check","text":"

Type-checks TypeScript without emitting files.

cd api\nnpm run type-check\n

What it does: - Runs tsc --noEmit - Reports type errors - Does NOT generate files

Output:

# Success (no output)\n\n# Errors\nsrc/modules/auth/auth.service.ts:45:12 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.\n

Use when: - Before committing code - In CI/CD pipeline - Debugging type errors

"},{"location":"v2/development/npm-commands/#npm-run-lint","title":"npm run lint","text":"

Runs ESLint to check code style.

cd api\nnpm run lint\n

What it does: - Runs eslint src/ --ext .ts - Reports style violations - Checks for common errors

Output:

# Success\n\u2714 150 files linted, 0 errors, 0 warnings\n\n# Errors\nsrc/modules/auth/auth.service.ts\n  45:12  error  'foo' is assigned a value but never used  @typescript-eslint/no-unused-vars\n

Use when: - Before committing code - Enforcing code style - Finding potential bugs

"},{"location":"v2/development/npm-commands/#npm-run-lintfix","title":"npm run lint:fix","text":"

Automatically fixes ESLint errors where possible.

cd api\nnpm run lint:fix\n

What it does: - Runs eslint src/ --ext .ts --fix - Auto-fixes style issues (formatting, imports, etc.) - Reports unfixable errors

Use when: - After writing new code - Cleaning up formatting - Before commit

"},{"location":"v2/development/npm-commands/#npm-run-format","title":"npm run format","text":"

Formats code with Prettier.

cd api\nnpm run format\n

What it does: - Runs prettier --write \"src/**/*.{ts,js,json}\" - Formats all TypeScript, JavaScript, and JSON files - Overwrites files in place

Use when: - Standardizing code format - After merge conflicts - Team-wide formatting

"},{"location":"v2/development/npm-commands/#npm-run-formatcheck","title":"npm run format:check","text":"

Checks if code is formatted correctly (CI).

cd api\nnpm run format:check\n

What it does: - Runs prettier --check \"src/**/*.{ts,js,json}\" - Reports unformatted files - Does NOT modify files

Use when: - In CI/CD pipeline - Verifying format before commit

"},{"location":"v2/development/npm-commands/#database-scripts","title":"Database Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-prismamigrate","title":"npm run prisma:migrate","text":"

Creates and applies a new Prisma migration.

cd api\nnpm run prisma:migrate\n# Or with name:\nnpx prisma migrate dev --name add_user_field\n

What it does: - Prompts for migration name - Generates SQL migration in prisma/migrations/ - Applies migration to development database - Regenerates Prisma Client

Output:

\u2714 Enter a name for the new migration: \u2026 add_user_field\nApplying migration `20260213000000_add_user_field`\n\u2714 Generated Prisma Client to ./node_modules/@prisma/client\n

Use when: - Changing database schema - Adding new models - Modifying fields

"},{"location":"v2/development/npm-commands/#npm-run-prismadeploy","title":"npm run prisma:deploy","text":"

Applies pending migrations (production).

cd api\nnpm run prisma:deploy\n

What it does: - Runs prisma migrate deploy - Applies unapplied migrations only - Does NOT create new migrations - Safe for production

Output:

Environment variables loaded from .env\nDatasource \"db\": PostgreSQL database \"changemaker_v2_db\"\n\n2 migrations found in prisma/migrations\n\nApplying migration `20260213000000_add_user_field`\nAll migrations have been successfully applied.\n

Use when: - Deploying to production - Applying migrations in Docker - CI/CD deployment

"},{"location":"v2/development/npm-commands/#npm-run-prismaseed","title":"npm run prisma:seed","text":"

Seeds database with initial data.

cd api\nnpm run prisma:seed\n

What it does: - Runs tsx prisma/seed.ts - Creates admin user - Creates default settings - Creates sample blocks

Output:

Running seed command `tsx prisma/seed.ts` ...\nSeeding database...\nCreated default settings\nCreated admin user: admin@example.com\nCreated 10 sample blocks\nSeed completed successfully\n

Use when: - First-time setup - After reset - Populating test data

"},{"location":"v2/development/npm-commands/#npm-run-prismastudio","title":"npm run prisma:studio","text":"

Opens Prisma Studio (database GUI).

cd api\nnpm run prisma:studio\n

What it does: - Runs prisma studio - Opens browser at http://localhost:5555 - Shows all tables and data - Allows CRUD operations

Use when: - Inspecting database - Manual data editing - Debugging data issues

"},{"location":"v2/development/npm-commands/#npm-run-prismareset","title":"npm run prisma:reset","text":"

Resets database (DESTRUCTIVE).

cd api\nnpm run prisma:reset\n

What it does: - Drops all tables - Re-applies all migrations - Runs seed script - DELETES ALL DATA

Output:

\u26a0\ufe0f  You are about to drop the database 'changemaker_v2_db'\n   All data will be lost.\n\nDo you want to continue? [y/N]: y\n\nDatabase reset successful\nMigrations applied\nSeed completed\n

Use when: - Starting fresh in development - Fixing migration conflicts - NEVER in production

"},{"location":"v2/development/npm-commands/#npm-run-prismavalidate","title":"npm run prisma:validate","text":"

Validates Prisma schema.

cd api\nnpm run prisma:validate\n

What it does: - Runs prisma validate - Checks schema syntax - Verifies relations - Does NOT touch database

Output:

# Success\nThe schema is valid \u2714\n\n# Errors\nError validating model \"User\": Field \"foo\" references unknown model \"Bar\"\n

Use when: - After editing schema - Before creating migration - In CI/CD pipeline

"},{"location":"v2/development/npm-commands/#npm-run-drizzlepush","title":"npm run drizzle:push","text":"

Pushes Drizzle schema changes to database (Media API).

cd api\nnpm run drizzle:push\n

What it does: - Runs drizzle-kit push - Syncs src/modules/media/db/schema.ts to database - Does NOT create migration files - Direct schema sync

Output:

Reading config from drizzle.config.ts\nPushing schema to database...\n\u2714 Schema pushed successfully\n

Use when: - Changing Media API tables (videos, jobs, reactions) - Rapid prototyping (no migrations) - Development only

"},{"location":"v2/development/npm-commands/#npm-run-drizzlestudio","title":"npm run drizzle:studio","text":"

Opens Drizzle Studio (database GUI for Media API).

cd api\nnpm run drizzle:studio\n

What it does: - Runs drizzle-kit studio - Opens browser at http://localhost:4983 - Shows Media API tables only

Use when: - Inspecting media tables - Debugging video data - Manual media data editing

"},{"location":"v2/development/npm-commands/#testing-scripts","title":"Testing Scripts","text":""},{"location":"v2/development/npm-commands/#npm-test","title":"npm test","text":"

Runs all tests (when configured).

cd api\nnpm test\n

What it does: - Runs Jest test suite - Executes *.test.ts files - Reports pass/fail

Note: Tests are part of Phase 15 (in progress).

"},{"location":"v2/development/npm-commands/#npm-run-testwatch","title":"npm run test:watch","text":"

Runs tests in watch mode.

cd api\nnpm run test:watch\n

What it does: - Runs jest --watch - Re-runs tests on file changes

"},{"location":"v2/development/npm-commands/#npm-run-testcoverage","title":"npm run test:coverage","text":"

Runs tests with coverage report.

cd api\nnpm run test:coverage\n

What it does: - Runs jest --coverage - Generates coverage report in coverage/

"},{"location":"v2/development/npm-commands/#utility-scripts","title":"Utility Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-envvalidate","title":"npm run env:validate","text":"

Validates required environment variables.

cd api\nnpm run env:validate\n

What it does: - Checks .env has required vars - Uses Zod validation (from config/env.ts) - Fails if vars missing/invalid

Output:

# Success\n\u2714 Environment variables valid\n\n# Errors\nError: Missing required environment variables:\n  - JWT_ACCESS_SECRET\n  - REDIS_PASSWORD\n

Use when: - After editing .env - Before deployment - Debugging config issues

"},{"location":"v2/development/npm-commands/#admin-scripts","title":"Admin Scripts","text":"

Location: admin/package.json

"},{"location":"v2/development/npm-commands/#development-scripts_1","title":"Development Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-dev_1","title":"npm run dev","text":"

Starts Vite development server with HMR.

cd admin\nnpm run dev\n

What it does: - Runs vite - Starts dev server on port ADMIN_PORT (default: 3000) - Enables Hot Module Replacement (HMR) - Proxies API requests to VITE_API_URL

Output:

  VITE v5.x.x  ready in 500 ms\n\n  \u279c  Local:   http://localhost:3000/\n  \u279c  Network: use --host to expose\n

Use when: - Developing frontend components - Testing UI changes - Working on React code

"},{"location":"v2/development/npm-commands/#build-scripts_1","title":"Build Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-build_1","title":"npm run build","text":"

Builds production-optimized bundle.

cd admin\nnpm run build\n

What it does: - Runs tsc --noEmit && vite build - Type-checks TypeScript - Bundles JavaScript/CSS - Optimizes assets (minify, tree-shake) - Outputs to dist/

Output:

vite v5.x.x building for production...\n\u2713 1245 modules transformed.\ndist/index.html                   0.45 kB\ndist/assets/index-a1b2c3d4.js   245.67 kB \u2502 gzip: 78.23 kB\ndist/assets/index-e5f6g7h8.css   12.34 kB \u2502 gzip:  3.45 kB\n\u2713 built in 15.23s\n

Use when: - Preparing for production deployment - Creating Docker image - Verifying build size

"},{"location":"v2/development/npm-commands/#npm-run-preview","title":"npm run preview","text":"

Previews production build locally.

cd admin\nnpm run preview\n

What it does: - Runs vite preview - Serves dist/ directory - Runs on port 4173 (Vite default)

Output:

  \u279c  Local:   http://localhost:4173/\n  \u279c  Network: use --host to expose\n

Use when: - Testing production build - Verifying optimizations - Before deployment

"},{"location":"v2/development/npm-commands/#code-quality-scripts_1","title":"Code Quality Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-type-check_1","title":"npm run type-check","text":"

Type-checks TypeScript without emitting files.

cd admin\nnpm run type-check\n

What it does: - Runs tsc --noEmit - Reports type errors - Checks all .ts and .tsx files

Output:

# Success (no output)\n\n# Errors\nsrc/pages/UsersPage.tsx:123:45 - error TS2339: Property 'foo' does not exist on type 'User'.\n

Use when: - Before committing code - In CI/CD pipeline - Debugging type errors

"},{"location":"v2/development/npm-commands/#npm-run-lint_1","title":"npm run lint","text":"

Runs ESLint to check code style.

cd admin\nnpm run lint\n

What it does: - Runs eslint src/ --ext .ts,.tsx - Reports style violations - Checks React best practices

Output:

# Success\n\u2714 85 files linted, 0 errors, 0 warnings\n\n# Errors\nsrc/pages/UsersPage.tsx\n  123:45  error  'foo' is assigned a value but never used  @typescript-eslint/no-unused-vars\n  200:10  warning  Missing dependency in useEffect  react-hooks/exhaustive-deps\n

Use when: - Before committing code - Enforcing code style - Finding potential bugs

"},{"location":"v2/development/npm-commands/#npm-run-lintfix_1","title":"npm run lint:fix","text":"

Automatically fixes ESLint errors where possible.

cd admin\nnpm run lint:fix\n

What it does: - Runs eslint src/ --ext .ts,.tsx --fix - Auto-fixes style issues - Reports unfixable errors

Use when: - After writing new code - Cleaning up formatting - Before commit

"},{"location":"v2/development/npm-commands/#npm-run-format_1","title":"npm run format","text":"

Formats code with Prettier.

cd admin\nnpm run format\n

What it does: - Runs prettier --write \"src/**/*.{ts,tsx,css,json}\" - Formats all source files - Overwrites files in place

Use when: - Standardizing code format - After merge conflicts - Team-wide formatting

"},{"location":"v2/development/npm-commands/#npm-run-formatcheck_1","title":"npm run format:check","text":"

Checks if code is formatted correctly (CI).

cd admin\nnpm run format:check\n

What it does: - Runs prettier --check \"src/**/*.{ts,tsx,css,json}\" - Reports unformatted files - Does NOT modify files

Use when: - In CI/CD pipeline - Verifying format before commit

"},{"location":"v2/development/npm-commands/#testing-scripts_1","title":"Testing Scripts","text":""},{"location":"v2/development/npm-commands/#npm-test_1","title":"npm test","text":"

Runs all tests (when configured).

cd admin\nnpm test\n

What it does: - Runs Vitest test suite - Executes *.test.tsx and *.spec.tsx files - Reports pass/fail

Note: Tests are part of Phase 15 (in progress).

"},{"location":"v2/development/npm-commands/#npm-run-testwatch_1","title":"npm run test:watch","text":"

Runs tests in watch mode.

cd admin\nnpm run test:watch\n

What it does: - Runs vitest - Re-runs tests on file changes

"},{"location":"v2/development/npm-commands/#npm-run-testui","title":"npm run test:ui","text":"

Runs tests with UI (Vitest UI).

cd admin\nnpm run test:ui\n

What it does: - Runs vitest --ui - Opens browser with test UI - Shows test results visually

"},{"location":"v2/development/npm-commands/#npm-run-testcoverage_1","title":"npm run test:coverage","text":"

Runs tests with coverage report.

cd admin\nnpm run test:coverage\n

What it does: - Runs vitest --coverage - Generates coverage report in coverage/

"},{"location":"v2/development/npm-commands/#utility-scripts_1","title":"Utility Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-clean_1","title":"npm run clean","text":"

Removes build artifacts and cache.

cd admin\nnpm run clean\n

What it does: - Deletes dist/ directory - Removes node_modules/.vite/ cache - Removes tsconfig.tsbuildinfo

Use when: - Starting fresh build - Fixing build cache issues - Cleaning up after development

"},{"location":"v2/development/npm-commands/#docker-commands","title":"Docker Commands","text":"

When running services in Docker, use docker compose exec to run npm scripts:

"},{"location":"v2/development/npm-commands/#api-in-docker","title":"API in Docker","text":"
# Development server (already running via docker compose up)\ndocker compose logs -f api\n\n# Type-check\ndocker compose exec api npm run type-check\n\n# Prisma migrate\ndocker compose exec api npx prisma migrate dev --name add_field\n\n# Prisma Studio\ndocker compose exec api npx prisma studio\n\n# Prisma seed\ndocker compose exec api npx prisma db seed\n\n# Drizzle push (Media API)\ndocker compose exec api npx drizzle-kit push\n\n# Lint\ndocker compose exec api npm run lint\n\n# Format\ndocker compose exec api npm run format\n
"},{"location":"v2/development/npm-commands/#admin-in-docker","title":"Admin in Docker","text":"
# Development server (already running via docker compose up)\ndocker compose logs -f admin\n\n# Type-check\ndocker compose exec admin npm run type-check\n\n# Lint\ndocker compose exec admin npm run lint\n\n# Build\ndocker compose exec admin npm run build\n
"},{"location":"v2/development/npm-commands/#rebuild-containers","title":"Rebuild Containers","text":"
# Rebuild after package.json changes\ndocker compose build --no-cache api admin\n\n# Restart services\ndocker compose restart api admin\n
"},{"location":"v2/development/npm-commands/#script-chaining","title":"Script Chaining","text":""},{"location":"v2/development/npm-commands/#sequential-execution","title":"Sequential Execution (&&)","text":"

Run scripts in sequence, stop on first failure:

# Type-check, then build\ncd api\nnpm run type-check && npm run build\n\n# Lint, format, type-check\ncd admin\nnpm run lint && npm run format && npm run type-check\n\n# Full quality check before commit\ncd api\nnpm run lint:fix && npm run format && npm run type-check && npm test\n
"},{"location":"v2/development/npm-commands/#parallel-execution-npm-run-all","title":"Parallel Execution (npm-run-all)","text":"

Install npm-run-all for parallel script execution:

# Install (project root)\nnpm install --save-dev npm-run-all\n\n# Add to package.json\n{\n  \"scripts\": {\n    \"check\": \"npm-run-all --parallel type-check lint test\"\n  }\n}\n\n# Run all checks in parallel\nnpm run check\n
"},{"location":"v2/development/npm-commands/#prepost-hooks","title":"Pre/Post Hooks","text":"

npm automatically runs pre* and post* scripts:

# package.json\n{\n  \"scripts\": {\n    \"prebuild\": \"npm run clean\",\n    \"build\": \"tsc --build\",\n    \"postbuild\": \"npm run copy-assets\"\n  }\n}\n\n# Running npm run build executes:\n# 1. npm run prebuild (clean)\n# 2. npm run build (tsc)\n# 3. npm run postbuild (copy-assets)\n
"},{"location":"v2/development/npm-commands/#common-script-combinations","title":"Common Script Combinations","text":""},{"location":"v2/development/npm-commands/#full-development-setup","title":"Full Development Setup","text":"
# 1. Install dependencies\ncd api && npm install && cd ..\ncd admin && npm install && cd ..\n\n# 2. Setup database\ncd api\nnpx prisma migrate deploy\nnpx prisma db seed\ncd ..\n\n# 3. Start development servers\n# Option A: Docker\ndocker compose up -d api admin\n\n# Option B: Local\ncd api && npm run dev  # Terminal 1\ncd admin && npm run dev  # Terminal 2\n
"},{"location":"v2/development/npm-commands/#pre-commit-quality-check","title":"Pre-Commit Quality Check","text":"
# API quality check\ncd api\nnpm run lint:fix\nnpm run format\nnpm run type-check\n# npm test  # When tests available\ncd ..\n\n# Admin quality check\ncd admin\nnpm run lint:fix\nnpm run format\nnpm run type-check\n# npm test  # When tests available\ncd ..\n\n# Commit if all pass\ngit add .\ngit commit -m \"feat: add new feature\"\n
"},{"location":"v2/development/npm-commands/#production-build","title":"Production Build","text":"
# Build API\ncd api\nnpm run clean\nnpm run build\ncd ..\n\n# Build Admin\ncd admin\nnpm run clean\nnpm run build\ncd ..\n\n# Build Docker images\ndocker compose build api admin\n\n# Start production services\ndocker compose -f docker-compose.yml up -d api admin\n
"},{"location":"v2/development/npm-commands/#database-migration-workflow","title":"Database Migration Workflow","text":"
# 1. Edit schema\ncd api\nvi prisma/schema.prisma\n\n# 2. Validate schema\nnpx prisma validate\n\n# 3. Create migration\nnpx prisma migrate dev --name add_user_field\n\n# 4. Verify migration SQL\ncat prisma/migrations/20260213000000_add_user_field/migration.sql\n\n# 5. Test on clean database\nnpx prisma migrate reset  # WARNING: Deletes data\nnpx prisma migrate deploy\nnpx prisma db seed\n\n# 6. Commit migration\ngit add prisma/migrations/ prisma/schema.prisma\ngit commit -m \"feat(db): add field to User model\"\n
"},{"location":"v2/development/npm-commands/#database-inspection","title":"Database Inspection","text":"
# Prisma Studio (main API)\ncd api\nnpx prisma studio\n# Open http://localhost:5555\n\n# Drizzle Studio (Media API)\ncd api\nnpx drizzle-kit studio\n# Open http://localhost:4983\n\n# Direct PostgreSQL query\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db\n# Run SQL queries\n
"},{"location":"v2/development/npm-commands/#full-type-check","title":"Full Type Check","text":"
# Type-check both projects\ncd api && npx tsc --noEmit && cd ..\ncd admin && npx tsc --noEmit && cd ..\n\n# Or create root script (package.json in project root)\n{\n  \"scripts\": {\n    \"type-check\": \"cd api && npm run type-check && cd ../admin && npm run type-check\"\n  }\n}\n\n# Run from root\nnpm run type-check\n
"},{"location":"v2/development/npm-commands/#cicd-integration","title":"CI/CD Integration","text":""},{"location":"v2/development/npm-commands/#github-actions-example","title":"GitHub Actions Example","text":"
name: CI\n\non: [push, pull_request]\n\njobs:\n  api:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-node@v3\n        with:\n          node-version: '20'\n\n      - name: Install dependencies\n        working-directory: ./api\n        run: npm ci\n\n      - name: Type check\n        working-directory: ./api\n        run: npm run type-check\n\n      - name: Lint\n        working-directory: ./api\n        run: npm run lint\n\n      - name: Format check\n        working-directory: ./api\n        run: npm run format:check\n\n      - name: Test\n        working-directory: ./api\n        run: npm test\n\n      - name: Build\n        working-directory: ./api\n        run: npm run build\n\n  admin:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-node@v3\n        with:\n          node-version: '20'\n\n      - name: Install dependencies\n        working-directory: ./admin\n        run: npm ci\n\n      - name: Type check\n        working-directory: ./admin\n        run: npm run type-check\n\n      - name: Lint\n        working-directory: ./admin\n        run: npm run lint\n\n      - name: Format check\n        working-directory: ./admin\n        run: npm run format:check\n\n      - name: Test\n        working-directory: ./admin\n        run: npm test\n\n      - name: Build\n        working-directory: ./admin\n        run: npm run build\n
"},{"location":"v2/development/npm-commands/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/development/npm-commands/#script-not-found","title":"Script Not Found","text":"

Problem:

npm ERR! missing script: dev\n

Solution: - Check package.json has the script defined - Verify you're in correct directory (api/ or admin/) - Run npm install to ensure dependencies installed

"},{"location":"v2/development/npm-commands/#permission-errors","title":"Permission Errors","text":"

Problem:

Error: EACCES: permission denied\n

Solution: - Don't use sudo npm (creates permission issues) - Fix npm permissions: sudo chown -R $(whoami) ~/.npm - Or use nvm for user-level Node.js installation

"},{"location":"v2/development/npm-commands/#port-already-in-use","title":"Port Already in Use","text":"

Problem:

Error: listen EADDRINUSE: address already in use :::4000\n

Solution: - Find and kill process using port: lsof -ti:4000 | xargs kill -9 - Or change port in .env: API_PORT=4002 - Or use Docker (isolated ports)

"},{"location":"v2/development/npm-commands/#typescript-errors-on-build","title":"TypeScript Errors on Build","text":"

Problem:

src/modules/auth/auth.service.ts:45:12 - error TS2339\n

Solution: - Fix type errors in code - Or check tsconfig.json is correct - Or update type definitions: npm install --save-dev @types/node@latest

"},{"location":"v2/development/npm-commands/#prisma-migration-conflicts","title":"Prisma Migration Conflicts","text":"

Problem:

Error: P3005 The database schema is not in sync with the migration history\n

Solution: - Development: npx prisma migrate reset (DELETES DATA) - Production: npx prisma migrate resolve --applied <migration_name> - Or create new migration to fix state

"},{"location":"v2/development/npm-commands/#npm-install-failures","title":"npm install Failures","text":"

Problem:

npm ERR! code ERESOLVE\nnpm ERR! ERESOLVE unable to resolve dependency tree\n

Solution: - Clear cache: npm cache clean --force - Delete and reinstall: rm -rf node_modules package-lock.json && npm install - Use --legacy-peer-deps flag: npm install --legacy-peer-deps

"},{"location":"v2/development/npm-commands/#vite-build-errors","title":"Vite Build Errors","text":"

Problem:

Error: Could not resolve entry module (index.html)\n

Solution: - Ensure index.html exists in admin/ - Check vite.config.ts has correct root - Clear cache: rm -rf node_modules/.vite && npm run dev

"},{"location":"v2/development/npm-commands/#best-practices","title":"Best Practices","text":""},{"location":"v2/development/npm-commands/#script-naming-conventions","title":"Script Naming Conventions","text":""},{"location":"v2/development/npm-commands/#script-organization","title":"Script Organization","text":"

Group related scripts:

{\n  \"scripts\": {\n    // Development\n    \"dev\": \"tsx watch src/server.ts\",\n    \"dev:media\": \"tsx watch src/media-server.ts\",\n\n    // Build\n    \"build\": \"tsc --build\",\n    \"clean\": \"rm -rf dist\",\n\n    // Quality\n    \"type-check\": \"tsc --noEmit\",\n    \"lint\": \"eslint src/ --ext .ts\",\n    \"lint:fix\": \"eslint src/ --ext .ts --fix\",\n    \"format\": \"prettier --write \\\"src/**/*.ts\\\"\",\n\n    // Database\n    \"prisma:migrate\": \"prisma migrate dev\",\n    \"prisma:deploy\": \"prisma migrate deploy\",\n    \"prisma:seed\": \"tsx prisma/seed.ts\"\n  }\n}\n
"},{"location":"v2/development/npm-commands/#environment-specific-scripts","title":"Environment-Specific Scripts","text":"

Use cross-env for environment variables:

npm install --save-dev cross-env\n
{\n  \"scripts\": {\n    \"dev\": \"cross-env NODE_ENV=development tsx watch src/server.ts\",\n    \"build\": \"cross-env NODE_ENV=production tsc --build\",\n    \"test\": \"cross-env NODE_ENV=test jest\"\n  }\n}\n
"},{"location":"v2/development/npm-commands/#script-documentation","title":"Script Documentation","text":"

Add comments in package.json:

{\n  \"scripts\": {\n    \"// Development\": \"\",\n    \"dev\": \"tsx watch src/server.ts\",\n\n    \"// Build\": \"\",\n    \"build\": \"tsc --build\",\n\n    \"// Quality\": \"\",\n    \"type-check\": \"tsc --noEmit\"\n  }\n}\n
"},{"location":"v2/development/npm-commands/#quick-reference","title":"Quick Reference","text":""},{"location":"v2/development/npm-commands/#api-scripts_1","title":"API Scripts","text":"
npm run dev              # Dev server (port 4000)\nnpm run dev:media        # Media API dev (port 4100)\nnpm run build            # Build for production\nnpm start                # Run production server\nnpm run type-check       # TypeScript validation\nnpm run lint             # ESLint check\nnpm run lint:fix         # ESLint auto-fix\nnpm run format           # Prettier format\nnpx prisma migrate dev   # Create migration\nnpx prisma migrate deploy # Apply migrations\nnpx prisma db seed       # Seed database\nnpx prisma studio        # Database GUI\nnpx drizzle-kit push     # Push Media schema\n
"},{"location":"v2/development/npm-commands/#admin-scripts_1","title":"Admin Scripts","text":"
npm run dev              # Dev server (port 3000)\nnpm run build            # Build for production\nnpm run preview          # Preview production build\nnpm run type-check       # TypeScript validation\nnpm run lint             # ESLint check\nnpm run lint:fix         # ESLint auto-fix\nnpm run format           # Prettier format\nnpm test                 # Run tests\nnpm run test:ui          # Test UI\n
"},{"location":"v2/development/npm-commands/#docker-scripts","title":"Docker Scripts","text":"
docker compose exec api npm run type-check\ndocker compose exec api npx prisma migrate dev\ndocker compose exec admin npm run lint\ndocker compose build --no-cache api admin\n
"},{"location":"v2/development/npm-commands/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/development/npm-commands/#summary","title":"Summary","text":"

You now know: - \u2705 All available npm scripts in API and Admin - \u2705 What each script does and when to use it - \u2705 How to run scripts in Docker containers - \u2705 How to chain scripts together - \u2705 Common script combinations for workflows - \u2705 How to troubleshoot script errors - \u2705 Best practices for script organization

Quick Start:

# Development\ncd api && npm run dev\ncd admin && npm run dev\n\n# Pre-commit\ncd api && npm run lint:fix && npm run type-check\ncd admin && npm run lint:fix && npm run type-check\n\n# Production build\ncd api && npm run build\ncd admin && npm run build\n

"},{"location":"v2/development/testing/","title":"Testing Strategy and Guide","text":"

Comprehensive guide to testing Changemaker Lite V2, covering unit tests, integration tests, and end-to-end testing strategies.

"},{"location":"v2/development/testing/#overview","title":"Overview","text":"

Current Status: Phase 15 (Testing + Polish) in progress. Test infrastructure is being implemented.

This guide covers: - Testing philosophy and strategy - Test frameworks (Jest, Vitest, React Testing Library) - Writing tests for API and Frontend - Running tests and generating coverage - Testing best practices

"},{"location":"v2/development/testing/#testing-philosophy","title":"Testing Philosophy","text":""},{"location":"v2/development/testing/#test-pyramid","title":"Test Pyramid","text":"
       /\\\n      /E2E\\         \u2190 Few, high-value end-to-end tests\n     /------\\\n    /Integration\\   \u2190 Moderate integration tests\n   /------------\\\n  /   Unit Tests  \\ \u2190 Many, fast unit tests\n /----------------\\\n

Unit Tests (70%): - Test individual functions/components - Fast execution (milliseconds) - No external dependencies - Easy to write and maintain

Integration Tests (20%): - Test multiple units working together - Test API routes with database - Test user flows in frontend - Moderate execution time

End-to-End Tests (10%): - Test complete user journeys - Test across API and frontend - Slow execution (seconds) - Complex setup

"},{"location":"v2/development/testing/#testing-principles","title":"Testing Principles","text":"
  1. Test Behavior, Not Implementation
  2. Test what the code does, not how it does it
  3. Allows refactoring without breaking tests

  4. Arrange-Act-Assert (AAA) Pattern

  5. Arrange: Set up test data and mocks
  6. Act: Execute the code under test
  7. Assert: Verify expected behavior

  8. Independent Tests

  9. Each test runs in isolation
  10. No shared state between tests
  11. Tests can run in any order

  12. Fast Feedback

  13. Tests run quickly (< 1 second each)
  14. Run tests in watch mode during development
  15. Run full suite in CI/CD

  16. Readable Tests

  17. Clear test names describing what is tested
  18. Simple setup and assertions
  19. Good error messages when tests fail
"},{"location":"v2/development/testing/#test-frameworks","title":"Test Frameworks","text":""},{"location":"v2/development/testing/#api-testing-jest","title":"API Testing (Jest)","text":"

Framework: Jest Location: api/src/**/*.test.ts Config: api/jest.config.js

Installation:

cd api\nnpm install --save-dev jest @types/jest ts-jest\nnpm install --save-dev @types/supertest supertest\n

Configuration (jest.config.js):

module.exports = {\n  preset: 'ts-jest',\n  testEnvironment: 'node',\n  roots: ['<rootDir>/src'],\n  testMatch: ['**/*.test.ts'],\n  collectCoverageFrom: [\n    'src/**/*.{ts,tsx}',\n    '!src/**/*.d.ts',\n    '!src/**/*.test.ts'\n  ],\n  coverageThreshold: {\n    global: {\n      branches: 80,\n      functions: 80,\n      lines: 80,\n      statements: 80\n    }\n  }\n};\n

"},{"location":"v2/development/testing/#frontend-testing-vitest-react-testing-library","title":"Frontend Testing (Vitest + React Testing Library)","text":"

Framework: Vitest (Vite-native test runner) Component Testing: React Testing Library Location: admin/src/**/*.test.tsx, admin/src/**/*.spec.tsx Config: admin/vitest.config.ts

Installation:

cd admin\nnpm install --save-dev vitest @vitest/ui\nnpm install --save-dev @testing-library/react @testing-library/jest-dom\nnpm install --save-dev @testing-library/user-event\n

Configuration (vitest.config.ts):

import { defineConfig } from 'vitest/config';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n  plugins: [react()],\n  test: {\n    environment: 'jsdom',\n    globals: true,\n    setupFiles: './src/test/setup.ts',\n    coverage: {\n      provider: 'v8',\n      reporter: ['text', 'json', 'html'],\n      exclude: [\n        'node_modules/',\n        'src/test/',\n        '**/*.d.ts',\n        '**/*.config.*',\n        '**/mockData'\n      ]\n    }\n  }\n});\n

Setup File (admin/src/test/setup.ts):

import '@testing-library/jest-dom';\nimport { expect, afterEach } from 'vitest';\nimport { cleanup } from '@testing-library/react';\n\n// Cleanup after each test\nafterEach(() => {\n  cleanup();\n});\n

"},{"location":"v2/development/testing/#api-testing","title":"API Testing","text":""},{"location":"v2/development/testing/#unit-tests-service-layer","title":"Unit Tests (Service Layer)","text":"

Test business logic in service files:

Example: api/src/modules/auth/auth.service.test.ts

import { describe, it, expect, beforeEach, vi } from 'vitest';\nimport { AuthService } from './auth.service';\nimport { PrismaClient } from '@prisma/client';\nimport bcrypt from 'bcryptjs';\n\n// Mock Prisma\nvi.mock('@prisma/client');\n\ndescribe('AuthService', () => {\n  let authService: AuthService;\n  let mockPrisma: any;\n\n  beforeEach(() => {\n    mockPrisma = {\n      user: {\n        findUnique: vi.fn(),\n        create: vi.fn()\n      }\n    };\n    authService = new AuthService(mockPrisma);\n  });\n\n  describe('login', () => {\n    it('should return tokens for valid credentials', async () => {\n      // Arrange\n      const email = 'test@example.com';\n      const password = 'Password123!';\n      const hashedPassword = await bcrypt.hash(password, 10);\n\n      mockPrisma.user.findUnique.mockResolvedValue({\n        id: 1,\n        email,\n        password: hashedPassword,\n        role: 'USER'\n      });\n\n      // Act\n      const result = await authService.login(email, password);\n\n      // Assert\n      expect(result).toHaveProperty('accessToken');\n      expect(result).toHaveProperty('refreshToken');\n      expect(result.user.email).toBe(email);\n    });\n\n    it('should throw error for invalid password', async () => {\n      // Arrange\n      const email = 'test@example.com';\n      const hashedPassword = await bcrypt.hash('correctpass', 10);\n\n      mockPrisma.user.findUnique.mockResolvedValue({\n        id: 1,\n        email,\n        password: hashedPassword,\n        role: 'USER'\n      });\n\n      // Act & Assert\n      await expect(\n        authService.login(email, 'wrongpass')\n      ).rejects.toThrow('Invalid credentials');\n    });\n\n    it('should throw error for non-existent user', async () => {\n      // Arrange\n      mockPrisma.user.findUnique.mockResolvedValue(null);\n\n      // Act & Assert\n      await expect(\n        authService.login('nonexistent@example.com', 'password')\n      ).rejects.toThrow('Invalid credentials');\n    });\n  });\n\n  describe('register', () => {\n    it('should create new user with hashed password', async () => {\n      // Arrange\n      const email = 'new@example.com';\n      const password = 'Password123!';\n\n      mockPrisma.user.findUnique.mockResolvedValue(null);\n      mockPrisma.user.create.mockResolvedValue({\n        id: 1,\n        email,\n        role: 'USER'\n      });\n\n      // Act\n      const result = await authService.register(email, password);\n\n      // Assert\n      expect(mockPrisma.user.create).toHaveBeenCalledWith({\n        data: expect.objectContaining({\n          email,\n          password: expect.any(String),\n          role: 'USER'\n        })\n      });\n      expect(result.user.email).toBe(email);\n    });\n\n    it('should throw error if user already exists', async () => {\n      // Arrange\n      mockPrisma.user.findUnique.mockResolvedValue({\n        id: 1,\n        email: 'existing@example.com'\n      });\n\n      // Act & Assert\n      await expect(\n        authService.register('existing@example.com', 'Password123!')\n      ).rejects.toThrow('User already exists');\n    });\n  });\n});\n
"},{"location":"v2/development/testing/#integration-tests-routes","title":"Integration Tests (Routes)","text":"

Test API endpoints with database:

Example: api/src/modules/auth/auth.routes.test.ts

import { describe, it, expect, beforeAll, afterAll } from 'vitest';\nimport request from 'supertest';\nimport { app } from '../../server';\nimport { PrismaClient } from '@prisma/client';\n\nconst prisma = new PrismaClient();\n\ndescribe('Auth Routes', () => {\n  beforeAll(async () => {\n    // Setup test database\n    await prisma.$connect();\n  });\n\n  afterAll(async () => {\n    // Cleanup\n    await prisma.user.deleteMany();\n    await prisma.$disconnect();\n  });\n\n  describe('POST /api/auth/register', () => {\n    it('should register new user', async () => {\n      const response = await request(app)\n        .post('/api/auth/register')\n        .send({\n          email: 'test@example.com',\n          password: 'Password123!'\n        })\n        .expect(201);\n\n      expect(response.body).toHaveProperty('accessToken');\n      expect(response.body).toHaveProperty('refreshToken');\n      expect(response.body.user.email).toBe('test@example.com');\n    });\n\n    it('should return 400 for invalid email', async () => {\n      const response = await request(app)\n        .post('/api/auth/register')\n        .send({\n          email: 'invalid-email',\n          password: 'Password123!'\n        })\n        .expect(400);\n\n      expect(response.body).toHaveProperty('error');\n    });\n\n    it('should return 409 for existing user', async () => {\n      // Create user first\n      await request(app)\n        .post('/api/auth/register')\n        .send({\n          email: 'existing@example.com',\n          password: 'Password123!'\n        });\n\n      // Try to create again\n      const response = await request(app)\n        .post('/api/auth/register')\n        .send({\n          email: 'existing@example.com',\n          password: 'Password123!'\n        })\n        .expect(409);\n\n      expect(response.body.error).toContain('already exists');\n    });\n  });\n\n  describe('POST /api/auth/login', () => {\n    it('should login with valid credentials', async () => {\n      // Register user first\n      await request(app)\n        .post('/api/auth/register')\n        .send({\n          email: 'login@example.com',\n          password: 'Password123!'\n        });\n\n      // Login\n      const response = await request(app)\n        .post('/api/auth/login')\n        .send({\n          email: 'login@example.com',\n          password: 'Password123!'\n        })\n        .expect(200);\n\n      expect(response.body).toHaveProperty('accessToken');\n      expect(response.body).toHaveProperty('refreshToken');\n    });\n\n    it('should return 401 for invalid password', async () => {\n      const response = await request(app)\n        .post('/api/auth/login')\n        .send({\n          email: 'login@example.com',\n          password: 'WrongPassword!'\n        })\n        .expect(401);\n\n      expect(response.body.error).toContain('Invalid credentials');\n    });\n  });\n});\n
"},{"location":"v2/development/testing/#database-testing","title":"Database Testing","text":"

Use separate test database:

Environment Variable (.env.test):

DATABASE_URL=postgresql://changemaker_v2:password@localhost:5433/changemaker_v2_test_db\n

Setup Script (api/src/test/setup.ts):

import { PrismaClient } from '@prisma/client';\nimport { execSync } from 'child_process';\n\nconst prisma = new PrismaClient();\n\nexport async function setupTestDatabase() {\n  // Apply migrations\n  execSync('npx prisma migrate deploy', {\n    env: { ...process.env, DATABASE_URL: process.env.TEST_DATABASE_URL }\n  });\n\n  // Clean data\n  await prisma.user.deleteMany();\n  await prisma.campaign.deleteMany();\n  // ... delete all tables\n}\n\nexport async function teardownTestDatabase() {\n  await prisma.$disconnect();\n}\n

"},{"location":"v2/development/testing/#frontend-testing","title":"Frontend Testing","text":""},{"location":"v2/development/testing/#component-unit-tests","title":"Component Unit Tests","text":"

Test individual React components:

Example: admin/src/components/UserCard.test.tsx

import { describe, it, expect } from 'vitest';\nimport { render, screen } from '@testing-library/react';\nimport { UserCard } from './UserCard';\n\ndescribe('UserCard', () => {\n  it('renders user information', () => {\n    const user = {\n      id: 1,\n      email: 'test@example.com',\n      role: 'USER',\n      name: 'Test User'\n    };\n\n    render(<UserCard user={user} />);\n\n    expect(screen.getByText('Test User')).toBeInTheDocument();\n    expect(screen.getByText('test@example.com')).toBeInTheDocument();\n    expect(screen.getByText('USER')).toBeInTheDocument();\n  });\n\n  it('renders \"No name\" when name is null', () => {\n    const user = {\n      id: 1,\n      email: 'test@example.com',\n      role: 'USER',\n      name: null\n    };\n\n    render(<UserCard user={user} />);\n\n    expect(screen.getByText('No name')).toBeInTheDocument();\n  });\n});\n
"},{"location":"v2/development/testing/#component-integration-tests","title":"Component Integration Tests","text":"

Test user interactions:

Example: admin/src/pages/LoginPage.test.tsx

import { describe, it, expect, vi } from 'vitest';\nimport { render, screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { LoginPage } from './LoginPage';\nimport { BrowserRouter } from 'react-router-dom';\nimport * as api from '../lib/api';\n\n// Mock API\nvi.mock('../lib/api');\n\ndescribe('LoginPage', () => {\n  it('submits login form with valid credentials', async () => {\n    const user = userEvent.setup();\n    const mockLogin = vi.spyOn(api, 'login').mockResolvedValue({\n      accessToken: 'token',\n      refreshToken: 'refresh',\n      user: { id: 1, email: 'test@example.com', role: 'USER' }\n    });\n\n    render(\n      <BrowserRouter>\n        <LoginPage />\n      </BrowserRouter>\n    );\n\n    // Fill form\n    await user.type(screen.getByLabelText(/email/i), 'test@example.com');\n    await user.type(screen.getByLabelText(/password/i), 'Password123!');\n\n    // Submit\n    await user.click(screen.getByRole('button', { name: /login/i }));\n\n    // Verify API called\n    await waitFor(() => {\n      expect(mockLogin).toHaveBeenCalledWith({\n        email: 'test@example.com',\n        password: 'Password123!'\n      });\n    });\n  });\n\n  it('shows error for invalid credentials', async () => {\n    const user = userEvent.setup();\n    vi.spyOn(api, 'login').mockRejectedValue(\n      new Error('Invalid credentials')\n    );\n\n    render(\n      <BrowserRouter>\n        <LoginPage />\n      </BrowserRouter>\n    );\n\n    await user.type(screen.getByLabelText(/email/i), 'test@example.com');\n    await user.type(screen.getByLabelText(/password/i), 'wrong');\n    await user.click(screen.getByRole('button', { name: /login/i }));\n\n    await waitFor(() => {\n      expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();\n    });\n  });\n\n  it('disables submit button while loading', async () => {\n    const user = userEvent.setup();\n    vi.spyOn(api, 'login').mockImplementation(\n      () => new Promise(resolve => setTimeout(resolve, 1000))\n    );\n\n    render(\n      <BrowserRouter>\n        <LoginPage />\n      </BrowserRouter>\n    );\n\n    const submitButton = screen.getByRole('button', { name: /login/i });\n\n    await user.type(screen.getByLabelText(/email/i), 'test@example.com');\n    await user.type(screen.getByLabelText(/password/i), 'Password123!');\n    await user.click(submitButton);\n\n    expect(submitButton).toBeDisabled();\n  });\n});\n
"},{"location":"v2/development/testing/#testing-hooks","title":"Testing Hooks","text":"

Test custom React hooks:

Example: admin/src/hooks/useDebounce.test.ts

import { describe, it, expect, vi } from 'vitest';\nimport { renderHook, waitFor } from '@testing-library/react';\nimport { useDebounce } from './useDebounce';\n\ndescribe('useDebounce', () => {\n  it('debounces value changes', async () => {\n    const { result, rerender } = renderHook(\n      ({ value, delay }) => useDebounce(value, delay),\n      { initialProps: { value: 'initial', delay: 500 } }\n    );\n\n    expect(result.current).toBe('initial');\n\n    // Change value\n    rerender({ value: 'updated', delay: 500 });\n\n    // Value should not change immediately\n    expect(result.current).toBe('initial');\n\n    // Wait for debounce\n    await waitFor(() => {\n      expect(result.current).toBe('updated');\n    }, { timeout: 600 });\n  });\n});\n
"},{"location":"v2/development/testing/#testing-zustand-stores","title":"Testing Zustand Stores","text":"

Test state management:

Example: admin/src/stores/auth.store.test.ts

import { describe, it, expect, beforeEach } from 'vitest';\nimport { renderHook, act } from '@testing-library/react';\nimport { useAuthStore } from './auth.store';\n\ndescribe('Auth Store', () => {\n  beforeEach(() => {\n    // Reset store before each test\n    const { result } = renderHook(() => useAuthStore());\n    act(() => {\n      result.current.logout();\n    });\n  });\n\n  it('sets user on login', () => {\n    const { result } = renderHook(() => useAuthStore());\n\n    act(() => {\n      result.current.setUser({\n        id: 1,\n        email: 'test@example.com',\n        role: 'USER'\n      });\n    });\n\n    expect(result.current.user).toEqual({\n      id: 1,\n      email: 'test@example.com',\n      role: 'USER'\n    });\n    expect(result.current.isAuthenticated).toBe(true);\n  });\n\n  it('clears user on logout', () => {\n    const { result } = renderHook(() => useAuthStore());\n\n    act(() => {\n      result.current.setUser({\n        id: 1,\n        email: 'test@example.com',\n        role: 'USER'\n      });\n    });\n\n    expect(result.current.isAuthenticated).toBe(true);\n\n    act(() => {\n      result.current.logout();\n    });\n\n    expect(result.current.user).toBeNull();\n    expect(result.current.isAuthenticated).toBe(false);\n  });\n});\n
"},{"location":"v2/development/testing/#running-tests","title":"Running Tests","text":""},{"location":"v2/development/testing/#run-all-tests","title":"Run All Tests","text":"
# API tests\ncd api\nnpm test\n\n# Frontend tests\ncd admin\nnpm test\n
"},{"location":"v2/development/testing/#watch-mode","title":"Watch Mode","text":"

Run tests automatically on file changes:

# API tests (Jest watch)\ncd api\nnpm run test:watch\n\n# Frontend tests (Vitest watch)\ncd admin\nnpm run test:watch\n
"},{"location":"v2/development/testing/#run-specific-tests","title":"Run Specific Tests","text":"
# Run specific test file\nnpm test -- auth.service.test.ts\n\n# Run tests matching pattern\nnpm test -- --testNamePattern=\"login\"\n\n# Run tests in specific directory\nnpm test -- src/modules/auth/\n
"},{"location":"v2/development/testing/#coverage-reports","title":"Coverage Reports","text":"

Generate test coverage:

# API coverage\ncd api\nnpm run test:coverage\n\n# Frontend coverage\ncd admin\nnpm run test:coverage\n

Coverage output:

File                | % Stmts | % Branch | % Funcs | % Lines |\n--------------------|---------|----------|---------|---------|\nAll files           |   82.45 |    75.33 |   80.12 |   83.21 |\n auth/              |   95.23 |    89.47 |   93.75 |   96.15 |\n  auth.service.ts   |   97.14 |    91.67 |   100   |   98.21 |\n  auth.routes.ts    |   93.33 |    87.50 |   87.50 |   94.12 |\n

HTML report: - Located in coverage/ directory - Open coverage/index.html in browser - Shows line-by-line coverage

"},{"location":"v2/development/testing/#cicd-testing","title":"CI/CD Testing","text":"

GitHub Actions Example:

name: Tests\n\non: [push, pull_request]\n\njobs:\n  api-tests:\n    runs-on: ubuntu-latest\n    services:\n      postgres:\n        image: postgres:16\n        env:\n          POSTGRES_PASSWORD: test\n        options: >-\n          --health-cmd pg_isready\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-node@v3\n        with:\n          node-version: '20'\n\n      - name: Install dependencies\n        working-directory: ./api\n        run: npm ci\n\n      - name: Run migrations\n        working-directory: ./api\n        env:\n          DATABASE_URL: postgresql://postgres:test@localhost:5432/test\n        run: npx prisma migrate deploy\n\n      - name: Run tests\n        working-directory: ./api\n        run: npm test -- --coverage\n\n      - name: Upload coverage\n        uses: codecov/codecov-action@v3\n        with:\n          files: ./api/coverage/coverage-final.json\n\n  frontend-tests:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-node@v3\n        with:\n          node-version: '20'\n\n      - name: Install dependencies\n        working-directory: ./admin\n        run: npm ci\n\n      - name: Run tests\n        working-directory: ./admin\n        run: npm test -- --coverage\n\n      - name: Upload coverage\n        uses: codecov/codecov-action@v3\n        with:\n          files: ./admin/coverage/coverage-final.json\n
"},{"location":"v2/development/testing/#mocking","title":"Mocking","text":""},{"location":"v2/development/testing/#mocking-api-calls-frontend","title":"Mocking API Calls (Frontend)","text":"
// Mock axios\nvi.mock('../lib/api', () => ({\n  api: {\n    get: vi.fn(),\n    post: vi.fn(),\n    put: vi.fn(),\n    delete: vi.fn()\n  }\n}));\n\n// Use in test\nimport { api } from '../lib/api';\n\nvi.mocked(api.get).mockResolvedValue({\n  data: { users: [] }\n});\n
"},{"location":"v2/development/testing/#mocking-database-backend","title":"Mocking Database (Backend)","text":"
// Mock Prisma Client\nvi.mock('@prisma/client', () => ({\n  PrismaClient: vi.fn(() => ({\n    user: {\n      findUnique: vi.fn(),\n      findMany: vi.fn(),\n      create: vi.fn(),\n      update: vi.fn(),\n      delete: vi.fn()\n    }\n  }))\n}));\n
"},{"location":"v2/development/testing/#mocking-external-services","title":"Mocking External Services","text":"
// Mock email service\nvi.mock('../../services/email.service', () => ({\n  EmailService: {\n    sendEmail: vi.fn().mockResolvedValue(true)\n  }\n}));\n
"},{"location":"v2/development/testing/#mocking-environment-variables","title":"Mocking Environment Variables","text":"
// Set env var for test\nprocess.env.JWT_ACCESS_SECRET = 'test-secret';\n\n// Or use vi.stubEnv\nvi.stubEnv('API_URL', 'http://localhost:4000');\n
"},{"location":"v2/development/testing/#best-practices","title":"Best Practices","text":""},{"location":"v2/development/testing/#test-naming","title":"Test Naming","text":"

Use descriptive test names:

Good:

it('should return 401 for expired token', async () => {});\nit('should create user with hashed password', async () => {});\nit('should render error message for invalid email', () => {});\n

Bad:

it('works', async () => {});\nit('test login', async () => {});\nit('should work correctly', () => {});\n

"},{"location":"v2/development/testing/#test-organization","title":"Test Organization","text":"

Group related tests:

describe('AuthService', () => {\n  describe('login', () => {\n    it('should return tokens for valid credentials', () => {});\n    it('should throw error for invalid password', () => {});\n    it('should throw error for non-existent user', () => {});\n  });\n\n  describe('register', () => {\n    it('should create new user', () => {});\n    it('should hash password', () => {});\n    it('should throw error if user exists', () => {});\n  });\n});\n
"},{"location":"v2/development/testing/#setup-and-teardown","title":"Setup and Teardown","text":"

Use beforeEach/afterEach for common setup:

describe('UserService', () => {\n  let userService: UserService;\n  let mockPrisma: any;\n\n  beforeEach(() => {\n    mockPrisma = createMockPrisma();\n    userService = new UserService(mockPrisma);\n  });\n\n  afterEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it('...', () => {});\n});\n
"},{"location":"v2/development/testing/#avoid-test-interdependence","title":"Avoid Test Interdependence","text":"

Each test should be independent:

Good:

it('should create user', async () => {\n  const user = await createUser({ email: 'test@example.com' });\n  expect(user.email).toBe('test@example.com');\n});\n\nit('should update user', async () => {\n  const user = await createUser({ email: 'test@example.com' });\n  const updated = await updateUser(user.id, { name: 'New Name' });\n  expect(updated.name).toBe('New Name');\n});\n

Bad:

let userId;\n\nit('should create user', async () => {\n  const user = await createUser({ email: 'test@example.com' });\n  userId = user.id; // \u274c Shared state\n});\n\nit('should update user', async () => {\n  const updated = await updateUser(userId, { name: 'New Name' });\n  // \u274c Depends on previous test\n});\n

"},{"location":"v2/development/testing/#test-edge-cases","title":"Test Edge Cases","text":"

Test boundary conditions:

describe('pagination', () => {\n  it('should handle page 1', () => {});\n  it('should handle last page', () => {});\n  it('should handle empty results', () => {});\n  it('should handle invalid page number', () => {});\n  it('should handle page exceeding total', () => {});\n});\n
"},{"location":"v2/development/testing/#async-testing","title":"Async Testing","text":"

Always use async/await for async tests:

Good:

it('should fetch users', async () => {\n  const users = await userService.getUsers();\n  expect(users).toHaveLength(10);\n});\n

Bad:

it('should fetch users', () => {\n  userService.getUsers().then(users => {\n    expect(users).toHaveLength(10); // \u274c May not run\n  });\n});\n

"},{"location":"v2/development/testing/#coverage-requirements","title":"Coverage Requirements","text":"

Target coverage thresholds:

// jest.config.js / vitest.config.ts\ncoverageThreshold: {\n  global: {\n    branches: 80,\n    functions: 80,\n    lines: 80,\n    statements: 80\n  }\n}\n

What to test: - \u2705 Business logic (services) - \u2705 API routes - \u2705 UI components - \u2705 Custom hooks - \u2705 Utilities - \u274c Type definitions - \u274c Config files - \u274c Test files themselves

"},{"location":"v2/development/testing/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/development/testing/#tests-timing-out","title":"Tests Timing Out","text":"

Problem: Tests exceed timeout.

Solution:

// Increase timeout for specific test\nit('slow operation', async () => {\n  // ...\n}, 10000); // 10 second timeout\n\n// Or globally (vitest.config.ts)\nexport default defineConfig({\n  test: {\n    testTimeout: 10000\n  }\n});\n
"},{"location":"v2/development/testing/#mocks-not-working","title":"Mocks Not Working","text":"

Problem: Mocks not being used.

Solution:

// Mock must be at top of file, before imports\nvi.mock('../lib/api');\n\nimport { api } from '../lib/api';\n\n// Verify mock is being used\nconsole.log(vi.isMockFunction(api.get)); // Should be true\n
"},{"location":"v2/development/testing/#database-connection-errors","title":"Database Connection Errors","text":"

Problem: Tests fail with DB connection errors.

Solution:

// Use separate test database\nprocess.env.DATABASE_URL = 'postgresql://localhost/test_db';\n\n// Or mock database entirely\nvi.mock('@prisma/client');\n
"},{"location":"v2/development/testing/#react-testing-library-queries-failing","title":"React Testing Library Queries Failing","text":"

Problem: screen.getByText() doesn't find element.

Solution:

// Use findBy for async elements\nconst element = await screen.findByText('Loading...');\n\n// Use queryBy to check non-existence\nexpect(screen.queryByText('Error')).not.toBeInTheDocument();\n\n// Debug rendered output\nscreen.debug();\n
"},{"location":"v2/development/testing/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/development/testing/#summary","title":"Summary","text":"

You now know: - \u2705 Testing philosophy (test pyramid, AAA pattern) - \u2705 Test frameworks (Jest, Vitest, React Testing Library) - \u2705 How to write unit tests (services, components) - \u2705 How to write integration tests (routes, user flows) - \u2705 How to run tests and generate coverage - \u2705 How to mock dependencies - \u2705 Testing best practices - \u2705 How to integrate tests in CI/CD

Quick Start:

# Install dependencies (when Phase 15 complete)\ncd api && npm install --save-dev jest @types/jest ts-jest\ncd admin && npm install --save-dev vitest @vitest/ui\n\n# Run tests\nnpm test\n\n# Watch mode\nnpm run test:watch\n\n# Coverage\nnpm run test:coverage\n

"},{"location":"v2/development/typescript/","title":"TypeScript Best Practices","text":"

Comprehensive TypeScript guide for Changemaker Lite V2, covering type system fundamentals, common patterns, and V2-specific conventions.

"},{"location":"v2/development/typescript/#overview","title":"Overview","text":"

Changemaker Lite V2 uses TypeScript 5.x with strict mode enabled for maximum type safety.

Benefits: - Catch errors at compile time - Better IDE autocomplete and refactoring - Self-documenting code - Safer refactoring

This guide covers TypeScript best practices specific to V2 development.

"},{"location":"v2/development/typescript/#type-system-fundamentals","title":"Type System Fundamentals","text":""},{"location":"v2/development/typescript/#primitives","title":"Primitives","text":"
// Basic types\nconst name: string = 'John';\nconst age: number = 30;\nconst isActive: boolean = true;\nconst data: null = null;\nconst value: undefined = undefined;\n\n// Arrays\nconst numbers: number[] = [1, 2, 3];\nconst emails: Array<string> = ['a@example.com', 'b@example.com'];\n\n// Tuples\nconst userTuple: [number, string] = [1, 'John'];\nconst coordinate: [number, number] = [51.5074, -0.1278];\n
"},{"location":"v2/development/typescript/#objects","title":"Objects","text":"
// Object literal\nconst user: { id: number; email: string } = {\n  id: 1,\n  email: 'john@example.com'\n};\n\n// Interface (preferred for reusable types)\ninterface User {\n  id: number;\n  email: string;\n  name?: string; // Optional property\n  readonly role: string; // Read-only property\n}\n\n// Type alias (for unions, intersections, utilities)\ntype UserRole = 'USER' | 'ADMIN' | 'SUPER_ADMIN';\n
"},{"location":"v2/development/typescript/#functions","title":"Functions","text":"
// Function declaration\nfunction greet(name: string): string {\n  return `Hello, ${name}`;\n}\n\n// Arrow function\nconst add = (a: number, b: number): number => a + b;\n\n// Optional parameters\nfunction log(message: string, level?: string): void {\n  console.log(`[${level ?? 'INFO'}] ${message}`);\n}\n\n// Default parameters\nfunction paginate(page: number = 1, limit: number = 50) {\n  return { page, limit };\n}\n\n// Rest parameters\nfunction sum(...numbers: number[]): number {\n  return numbers.reduce((total, n) => total + n, 0);\n}\n\n// Async functions\nasync function fetchUser(id: number): Promise<User> {\n  const response = await fetch(`/api/users/${id}`);\n  return response.json();\n}\n
"},{"location":"v2/development/typescript/#unions-and-intersections","title":"Unions and Intersections","text":"
// Union (OR)\ntype Status = 'pending' | 'active' | 'completed';\ntype ID = number | string;\n\nfunction printId(id: ID) {\n  if (typeof id === 'string') {\n    console.log(id.toUpperCase());\n  } else {\n    console.log(id.toFixed(2));\n  }\n}\n\n// Intersection (AND)\ntype Timestamped = {\n  createdAt: Date;\n  updatedAt: Date;\n};\n\ntype User = {\n  id: number;\n  email: string;\n} & Timestamped;\n\n// User has: id, email, createdAt, updatedAt\n
"},{"location":"v2/development/typescript/#generics","title":"Generics","text":"
// Generic function\nfunction identity<T>(value: T): T {\n  return value;\n}\n\nconst num = identity<number>(42);\nconst str = identity<string>('hello');\n\n// Generic interface\ninterface Response<T> {\n  data: T;\n  error?: string;\n}\n\nconst userResponse: Response<User> = {\n  data: { id: 1, email: 'john@example.com' }\n};\n\n// Generic constraints\nfunction getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {\n  return obj[key];\n}\n\nconst user = { id: 1, email: 'john@example.com' };\nconst email = getProperty(user, 'email'); // Type: string\n
"},{"location":"v2/development/typescript/#utility-types","title":"Utility Types","text":"
// Partial - Makes all properties optional\ntype UpdateUserInput = Partial<User>;\n\n// Pick - Select specific properties\ntype UserPreview = Pick<User, 'id' | 'email'>;\n\n// Omit - Exclude specific properties\ntype UserWithoutPassword = Omit<User, 'password'>;\n\n// Required - Makes all properties required\ntype RequiredUser = Required<User>;\n\n// Readonly - Makes all properties read-only\ntype ImmutableUser = Readonly<User>;\n\n// Record - Object with specific key/value types\ntype UserMap = Record<number, User>;\n\n// ReturnType - Extract return type of function\nfunction getUser() {\n  return { id: 1, email: 'john@example.com' };\n}\ntype User = ReturnType<typeof getUser>;\n\n// Parameters - Extract parameter types\nfunction createUser(email: string, password: string) {}\ntype CreateUserParams = Parameters<typeof createUser>;\n// [string, string]\n
"},{"location":"v2/development/typescript/#common-v2-patterns","title":"Common V2 Patterns","text":""},{"location":"v2/development/typescript/#requestresponse-types","title":"Request/Response Types","text":"

API Request:

// Express Request with typed params, query, body\nimport { Request, Response } from 'express';\n\ninterface GetUserParams {\n  id: string; // Params are always strings\n}\n\ninterface GetUsersQuery {\n  page?: string;\n  limit?: string;\n  search?: string;\n}\n\ninterface CreateUserBody {\n  email: string;\n  password: string;\n  name?: string;\n}\n\n// Route handler\napp.get('/users/:id', (req: Request<GetUserParams>, res: Response) => {\n  const id = parseInt(req.params.id as string); // Cast to string\n  // ...\n});\n\napp.get('/users', (req: Request<{}, {}, {}, GetUsersQuery>, res: Response) => {\n  const page = parseInt(req.query.page ?? '1');\n  const limit = parseInt(req.query.limit ?? '50');\n  // ...\n});\n\napp.post('/users', (req: Request<{}, {}, CreateUserBody>, res: Response) => {\n  const { email, password, name } = req.body;\n  // ...\n});\n

Augmented Request (with user from JWT):

// api/src/types/express.d.ts\nimport { User } from '@prisma/client';\n\ndeclare global {\n  namespace Express {\n    interface Request {\n      user?: {\n        id: number;\n        email: string;\n        role: string;\n      };\n    }\n  }\n}\n\n// Usage in route\napp.get('/me', authenticate, (req: Request, res: Response) => {\n  const userId = req.user!.id; // Non-null assertion (safe after authenticate)\n  // ...\n});\n
"},{"location":"v2/development/typescript/#prisma-types","title":"Prisma Types","text":"

Generated Types:

import { User, Campaign, Location, Prisma } from '@prisma/client';\n\n// Model types\nconst user: User = {\n  id: 1,\n  email: 'john@example.com',\n  password: 'hashed...',\n  role: 'USER',\n  createdAt: new Date(),\n  updatedAt: new Date()\n};\n\n// Create input\nconst createData: Prisma.UserCreateInput = {\n  email: 'john@example.com',\n  password: 'hashed...',\n  role: 'USER'\n};\n\n// Unchecked create (with foreign keys)\nconst createCampaign: Prisma.CampaignUncheckedCreateInput = {\n  title: 'New Campaign',\n  createdByUserId: 1 // Can set FK directly\n};\n\n// Update input\nconst updateData: Prisma.UserUpdateInput = {\n  name: 'John Doe',\n  updatedAt: new Date()\n};\n\n// Where clause\nconst whereClause: Prisma.UserWhereInput = {\n  email: { contains: '@example.com' },\n  role: { in: ['USER', 'ADMIN'] },\n  createdAt: { gte: new Date('2024-01-01') }\n};\n\n// Include relations\nconst userWithCampaigns = await prisma.user.findUnique({\n  where: { id: 1 },\n  include: { campaigns: true }\n});\n// Type: User & { campaigns: Campaign[] }\n

JSON Fields:

// Prisma model with JSON field\nmodel Page {\n  id      Int   @id @default(autoincrement())\n  content Json  // JSON field\n}\n\n// Type-safe JSON usage\nimport { Prisma } from '@prisma/client';\n\ninterface BlockContent {\n  type: string;\n  data: Record<string, unknown>;\n}\n\nconst blocks: BlockContent[] = [\n  { type: 'text', data: { content: 'Hello' } }\n];\n\n// Cast to Prisma.InputJsonValue\nawait prisma.page.create({\n  data: {\n    content: blocks as unknown as Prisma.InputJsonValue\n  }\n});\n\n// Use Prisma.JsonNull for null\nawait prisma.page.update({\n  where: { id: 1 },\n  data: {\n    content: Prisma.JsonNull\n  }\n});\n
"},{"location":"v2/development/typescript/#drizzle-types","title":"Drizzle Types","text":"

Schema Types:

// api/src/modules/media/db/schema.ts\nimport { pgTable, serial, text, integer, timestamp } from 'drizzle-orm/pg-core';\n\nexport const videos = pgTable('videos', {\n  id: serial('id').primaryKey(),\n  filename: text('filename').notNull(),\n  title: text('title'),\n  duration: integer('duration'),\n  createdAt: timestamp('created_at').defaultNow().notNull()\n});\n\n// Infer types from schema\nexport type Video = typeof videos.$inferSelect;\nexport type NewVideo = typeof videos.$inferInsert;\n\n// Usage\nconst video: Video = {\n  id: 1,\n  filename: 'video.mp4',\n  title: 'My Video',\n  duration: 120,\n  createdAt: new Date()\n};\n\nconst newVideo: NewVideo = {\n  filename: 'video.mp4',\n  title: 'My Video',\n  duration: 120\n  // id and createdAt auto-generated\n};\n
"},{"location":"v2/development/typescript/#zod-schemas","title":"Zod Schemas","text":"

Validation Schemas:

import { z } from 'zod';\n\n// Login schema\nexport const loginSchema = z.object({\n  email: z.string().email(),\n  password: z.string().min(12)\n});\n\n// Infer TypeScript type from Zod schema\nexport type LoginInput = z.infer<typeof loginSchema>;\n\n// Usage in route\napp.post('/login', validate(loginSchema), (req: Request, res: Response) => {\n  const { email, password } = req.body as LoginInput;\n  // TypeScript knows email is string and password is string\n});\n\n// Complex schema\nexport const createCampaignSchema = z.object({\n  title: z.string().min(1).max(200),\n  description: z.string().optional(),\n  targetEmails: z.array(z.string().email()),\n  active: z.boolean().default(false),\n  settings: z.object({\n    allowResponses: z.boolean(),\n    moderateResponses: z.boolean()\n  }).optional()\n});\n\nexport type CreateCampaignInput = z.infer<typeof createCampaignSchema>;\n
"},{"location":"v2/development/typescript/#react-component-types","title":"React Component Types","text":"

Component Props:

// Prop types\ninterface UserCardProps {\n  user: User;\n  onEdit?: (user: User) => void;\n  className?: string;\n}\n\nexport function UserCard({ user, onEdit, className }: UserCardProps) {\n  return (\n    <div className={className} onClick={() => onEdit?.(user)}>\n      {user.name}\n    </div>\n  );\n}\n\n// Children prop\ninterface LayoutProps {\n  children: React.ReactNode;\n  title: string;\n}\n\nexport function Layout({ children, title }: LayoutProps) {\n  return (\n    <div>\n      <h1>{title}</h1>\n      {children}\n    </div>\n  );\n}\n\n// Generic component\ninterface ListProps<T> {\n  items: T[];\n  renderItem: (item: T) => React.ReactNode;\n}\n\nexport function List<T>({ items, renderItem }: ListProps<T>) {\n  return <div>{items.map(renderItem)}</div>;\n}\n

Event Handlers:

// Form events\nfunction LoginForm() {\n  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n    // ...\n  };\n\n  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    console.log(e.target.value);\n  };\n\n  return (\n    <form onSubmit={handleSubmit}>\n      <input onChange={handleChange} />\n    </form>\n  );\n}\n\n// Button click\nconst handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {\n  console.log(e.currentTarget);\n};\n

Hooks:

import { useState, useEffect, useRef } from 'react';\n\n// useState\nconst [count, setCount] = useState<number>(0);\nconst [user, setUser] = useState<User | null>(null);\n\n// useEffect\nuseEffect(() => {\n  async function fetchUser() {\n    const data = await api.get<User>('/user');\n    setUser(data);\n  }\n  fetchUser();\n}, []);\n\n// useRef\nconst inputRef = useRef<HTMLInputElement>(null);\nconst timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);\n\nuseEffect(() => {\n  if (inputRef.current) {\n    inputRef.current.focus();\n  }\n}, []);\n\n// useReducer\ntype State = { count: number };\ntype Action = { type: 'increment' } | { type: 'decrement' };\n\nconst reducer = (state: State, action: Action): State => {\n  switch (action.type) {\n    case 'increment':\n      return { count: state.count + 1 };\n    case 'decrement':\n      return { count: state.count - 1 };\n  }\n};\n\nconst [state, dispatch] = useReducer(reducer, { count: 0 });\n

Zustand Store:

import { create } from 'zustand';\n\ninterface AuthState {\n  user: User | null;\n  isAuthenticated: boolean;\n  setUser: (user: User | null) => void;\n  logout: () => void;\n}\n\nexport const useAuthStore = create<AuthState>((set) => ({\n  user: null,\n  isAuthenticated: false,\n\n  setUser: (user) => set({\n    user,\n    isAuthenticated: !!user\n  }),\n\n  logout: () => set({\n    user: null,\n    isAuthenticated: false\n  })\n}));\n\n// Usage\nconst { user, setUser, logout } = useAuthStore();\n
"},{"location":"v2/development/typescript/#type-safety","title":"Type Safety","text":""},{"location":"v2/development/typescript/#avoiding-any","title":"Avoiding any","text":"

Never use any (ESLint rule enforced):

Bad:

function processData(data: any) {\n  return data.foo.bar; // No type safety\n}\n

Good:

// Use unknown if type is truly unknown\nfunction processData(data: unknown) {\n  if (isValidData(data)) {\n    return data.foo.bar; // Safe after type guard\n  }\n  throw new Error('Invalid data');\n}\n\nfunction isValidData(data: unknown): data is { foo: { bar: string } } {\n  return (\n    typeof data === 'object' &&\n    data !== null &&\n    'foo' in data &&\n    typeof data.foo === 'object' &&\n    data.foo !== null &&\n    'bar' in data.foo\n  );\n}\n\n// Or define proper interface\ninterface ValidData {\n  foo: { bar: string };\n}\n\nfunction processData(data: ValidData) {\n  return data.foo.bar;\n}\n

"},{"location":"v2/development/typescript/#type-assertions","title":"Type Assertions","text":"

Use type assertions carefully:

Good:

// When you know more than TypeScript\nconst input = document.getElementById('email') as HTMLInputElement;\n\n// Safer: Use type guard\nif (input instanceof HTMLInputElement) {\n  input.value = 'test@example.com';\n}\n

Bad:

// Dangerous: Could be wrong\nconst data = response.data as User;\n\n// Better: Validate first\nconst data = validateUser(response.data);\n

"},{"location":"v2/development/typescript/#non-null-assertion","title":"Non-null Assertion","text":"

Use ! only when TypeScript can't infer non-null:

Good:

// After authentication middleware\napp.get('/me', authenticate, (req, res) => {\n  const userId = req.user!.id; // Safe: authenticate ensures user exists\n});\n\n// After null check\nconst user = await prisma.user.findUnique({ where: { id: 1 } });\nif (!user) {\n  throw new Error('User not found');\n}\nconsole.log(user!.email); // Safe: null checked above\n

Bad:

// Dangerous: Could be null\nconst user = await prisma.user.findUnique({ where: { id: 1 } });\nconsole.log(user!.email); // Could crash if user is null\n

"},{"location":"v2/development/typescript/#type-guards","title":"Type Guards","text":"

Create type guards for runtime validation:

// Type guard function\nfunction isUser(obj: unknown): obj is User {\n  return (\n    typeof obj === 'object' &&\n    obj !== null &&\n    'id' in obj &&\n    'email' in obj &&\n    typeof obj.id === 'number' &&\n    typeof obj.email === 'string'\n  );\n}\n\n// Usage\nfunction processUser(data: unknown) {\n  if (isUser(data)) {\n    console.log(data.email); // TypeScript knows data is User\n  }\n}\n\n// Discriminated union\ntype Shape =\n  | { kind: 'circle'; radius: number }\n  | { kind: 'square'; size: number };\n\nfunction area(shape: Shape): number {\n  switch (shape.kind) {\n    case 'circle':\n      return Math.PI * shape.radius ** 2; // TS knows: radius exists\n    case 'square':\n      return shape.size ** 2; // TS knows: size exists\n  }\n}\n
"},{"location":"v2/development/typescript/#common-v2-gotchas","title":"Common V2 Gotchas","text":""},{"location":"v2/development/typescript/#express-params-as-string-or-string","title":"Express Params as String or String[]","text":"

Problem: req.params.id type is string | string[] in Express 5.

Solution:

// Cast to string (if you expect single value)\nconst id = parseInt(req.params.id as string);\n\n// Or check type\nconst rawId = req.params.id;\nconst id = typeof rawId === 'string' ? parseInt(rawId) : undefined;\n
"},{"location":"v2/development/typescript/#useref-with-undefined","title":"useRef with Undefined","text":"

Problem: useRef<T>() requires explicit undefined.

Solution:

// Good\nconst ref = useRef<HTMLInputElement>(undefined);\nconst ref = useRef<HTMLInputElement | null>(null);\n\n// Bad\nconst ref = useRef<HTMLInputElement>(); // Type error\n
"},{"location":"v2/development/typescript/#prisma-json-fields","title":"Prisma JSON Fields","text":"

Problem: JSON arrays need cast.

Solution:

import { Prisma } from '@prisma/client';\n\n// Cast array to Prisma.InputJsonValue\nconst blocks: Block[] = [...];\nawait prisma.page.create({\n  data: {\n    content: blocks as unknown as Prisma.InputJsonValue\n  }\n});\n\n// Use Prisma.JsonNull for null\nawait prisma.page.update({\n  where: { id: 1 },\n  data: { content: Prisma.JsonNull }\n});\n
"},{"location":"v2/development/typescript/#mixing-and","title":"Mixing ?? and ||","text":"

Problem: Cannot mix ?? and || without parentheses.

Solution:

// Error\nconst value = a ?? b || c;\n\n// Good\nconst value = (a ?? b) || c;\nconst value = a ?? (b || c);\n
"},{"location":"v2/development/typescript/#record-cast","title":"Record Cast

Problem: Need to cast via unknown first.

Solution:

// Error\nconst obj: Record<string, unknown> = someData;\n\n// Good\nconst obj = someData as unknown as Record<string, unknown>;\n
","text":""},{"location":"v2/development/typescript/#dayjs-via-ant-design","title":"dayjs via Ant Design

Problem: dayjs available transitively.

Solution:

// No need to install dayjs separately\nimport dayjs from 'dayjs'; // Available via antd\n
","text":""},{"location":"v2/development/typescript/#requser-name-field","title":"req.user Name Field

Problem: JWT only has id, email, role (no name).

Solution:

// JWT payload\ninterface JWTPayload {\n  id: number;\n  email: string;\n  role: string;\n}\n\n// Augmented request\ndeclare global {\n  namespace Express {\n    interface Request {\n      user?: JWTPayload; // Not full User\n    }\n  }\n}\n\n// If you need name, fetch from database\nconst user = await prisma.user.findUnique({\n  where: { id: req.user!.id }\n});\nconsole.log(user?.name);\n
","text":""},{"location":"v2/development/typescript/#api-import-pattern","title":"API Import Pattern

Problem: Named export, not default.

Solution:

// Good\nimport { api } from '../lib/api';\n\n// Bad\nimport api from '../lib/api'; // Error\n
","text":""},{"location":"v2/development/typescript/#unchecked-createupdate","title":"Unchecked Create/Update

Problem: Setting foreign keys directly.

Solution:

// Use Unchecked variants\nconst data: Prisma.CampaignUncheckedCreateInput = {\n  title: 'Campaign',\n  createdByUserId: 1 // Can set FK directly\n};\n\n// Regular CreateInput requires nested create\nconst data: Prisma.CampaignCreateInput = {\n  title: 'Campaign',\n  createdBy: {\n    connect: { id: 1 }\n  }\n};\n
","text":""},{"location":"v2/development/typescript/#type-utilities","title":"Type Utilities","text":""},{"location":"v2/development/typescript/#custom-utility-types","title":"Custom Utility Types
// Make specific fields optional\ntype PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;\n\ntype UpdateUserInput = PartialBy<User, 'name' | 'email'>;\n// id required, name and email optional\n\n// Make specific fields required\ntype RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;\n\ntype UserWithEmail = RequiredBy<User, 'email'>;\n// All fields optional except email\n\n// Deep partial\ntype DeepPartial<T> = {\n  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];\n};\n\n// Nullable\ntype Nullable<T> = T | null;\n\ntype NullableUser = Nullable<User>;\n// User | null\n
","text":""},{"location":"v2/development/typescript/#type-extraction","title":"Type Extraction
// Extract specific keys\ntype UserKeys = keyof User;\n// 'id' | 'email' | 'password' | 'role' | ...\n\n// Extract value types\ntype UserEmail = User['email'];\n// string\n\n// Extract function return type\nfunction getUser() {\n  return { id: 1, email: 'john@example.com' };\n}\n\ntype User = ReturnType<typeof getUser>;\n// { id: number; email: string }\n\n// Extract promise result type\nasync function fetchUser(): Promise<User> {\n  // ...\n}\n\ntype FetchUserResult = Awaited<ReturnType<typeof fetchUser>>;\n// User (not Promise<User>)\n
","text":""},{"location":"v2/development/typescript/#performance","title":"Performance","text":""},{"location":"v2/development/typescript/#build-times","title":"Build Times

Optimize tsconfig.json:

{\n  \"compilerOptions\": {\n    \"skipLibCheck\": true, // Skip type checking node_modules\n    \"incremental\": true,  // Enable incremental compilation\n    \"tsBuildInfoFile\": \".tsbuildinfo\" // Cache file\n  }\n}\n

Type-check without emit:

# Faster than full build\nnpx tsc --noEmit\n
","text":""},{"location":"v2/development/typescript/#type-inference","title":"Type Inference

Let TypeScript infer when possible:

Good:

// TypeScript infers string[]\nconst emails = users.map(u => u.email);\n\n// TypeScript infers number\nconst total = amounts.reduce((sum, n) => sum + n, 0);\n

Bad:

// Unnecessary explicit type\nconst emails: string[] = users.map(u => u.email);\n

","text":""},{"location":"v2/development/typescript/#migration-from-javascript","title":"Migration from JavaScript","text":""},{"location":"v2/development/typescript/#gradual-typing","title":"Gradual Typing

Add types incrementally:

Step 1: Allow implicit any

{\n  \"compilerOptions\": {\n    \"noImplicitAny\": false\n  }\n}\n

Step 2: Add types to new code

// New functions with types\nfunction createUser(email: string, password: string): User {\n  // ...\n}\n

Step 3: Add types to existing code

// Old function (before)\nfunction getUser(id) {\n  return prisma.user.findUnique({ where: { id } });\n}\n\n// Add types (after)\nfunction getUser(id: number): Promise<User | null> {\n  return prisma.user.findUnique({ where: { id } });\n}\n

Step 4: Enable strict mode

{\n  \"compilerOptions\": {\n    \"strict\": true\n  }\n}\n
","text":""},{"location":"v2/development/typescript/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/development/typescript/#summary","title":"Summary","text":"

You now know: - \u2705 TypeScript type system fundamentals - \u2705 Common V2 patterns (Prisma, Drizzle, Zod, React) - \u2705 Type safety best practices (avoid any, type guards) - \u2705 V2-specific gotchas and solutions - \u2705 Custom utility types - \u2705 Performance optimization - \u2705 Gradual migration from JavaScript

Quick Reference:

// Prisma types\nimport { User, Prisma } from '@prisma/client';\n\n// Zod types\nconst schema = z.object({ email: z.string().email() });\ntype Input = z.infer<typeof schema>;\n\n// React types\ninterface Props {\n  user: User;\n  onEdit?: (user: User) => void;\n}\n\n// Type guards\nfunction isUser(obj: unknown): obj is User {\n  return typeof obj === 'object' && obj !== null && 'id' in obj;\n}\n\n// Utility types\ntype UpdateInput = Partial<User>;\ntype UserPreview = Pick<User, 'id' | 'email'>;\n

"},{"location":"v2/features/","title":"Feature Documentation","text":"

Welcome to the Changemaker Lite V2 feature documentation. This section provides end-to-end guides for complete features, showing how backend APIs, frontend pages, and database models work together to deliver functionality.

"},{"location":"v2/features/#documentation-structure","title":"Documentation Structure","text":"

Each feature guide includes:

"},{"location":"v2/features/#feature-categories","title":"Feature Categories","text":""},{"location":"v2/features/#influence-features","title":"Influence Features","text":"

Email advocacy campaigns and representative outreach:

"},{"location":"v2/features/#map-features","title":"Map Features","text":"

Geographic location management and canvassing:

"},{"location":"v2/features/#landing-pages","title":"Landing Pages","text":"

Website page building and management:

"},{"location":"v2/features/#email-templates","title":"Email Templates","text":"

Email template system for campaigns:

"},{"location":"v2/features/#media-features","title":"Media Features","text":"

Video library management:

"},{"location":"v2/features/#newsletter-integration","title":"Newsletter Integration","text":"

Listmonk newsletter platform integration:

"},{"location":"v2/features/#tunnel-management","title":"Tunnel Management","text":"

Pangolin tunnel for public access:

"},{"location":"v2/features/#observability","title":"Observability","text":"

Monitoring and metrics:

"},{"location":"v2/features/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/#quick-navigation","title":"Quick Navigation","text":""},{"location":"v2/features/#by-user-role","title":"By User Role","text":"

Administrators: - Campaign creation and management - Response moderation - User management - Location management - Shift scheduling - Email queue monitoring - Landing page editing

Public Users: - Campaign participation - Representative lookup - Email sending - Response submission - Shift signup - Media gallery browsing

Volunteers: - Canvassing with GPS - Visit recording - Shift assignments - Activity tracking - Route history

"},{"location":"v2/features/#by-use-case","title":"By Use Case","text":"

Advocacy Campaigns: 1. Create campaign 2. Configure representatives 3. Monitor email queue 4. Moderate responses

Canvassing Operations: 1. Import locations 2. Create geographic cuts 3. Schedule shifts 4. Track canvassing 5. Print walk sheets

Website Management: 1. Build landing pages 2. Manage content blocks 3. Export to MkDocs

Public Access: 1. Setup Pangolin tunnel 2. Configure Newt container 3. Monitor with observability

"},{"location":"v2/features/COMPLETION_STATUS/","title":"Phase 6 Features Documentation - Completion Status","text":""},{"location":"v2/features/COMPLETION_STATUS/#overview","title":"Overview","text":"

Phase 6 creates comprehensive end-to-end feature documentation showing how complete features work across backend + frontend + database layers.

Target: 26 feature documentation files Created: 6 files (23%) Remaining: 20 files (77%)

"},{"location":"v2/features/COMPLETION_STATUS/#completed-files-626","title":"Completed Files (6/26)","text":""},{"location":"v2/features/COMPLETION_STATUS/#influence-features-56","title":"Influence Features (\u215a)","text":"

\u2705 campaigns.md (1,118 lines) - Campaign management system with lifecycle, feature flags, admin/public workflows \u2705 representatives.md (1,048 lines) - Represent API integration, caching, postal code lookup \u2705 responses.md (1,064 lines) - Response wall submission, moderation, upvoting, email verification \u2705 email-queue.md (994 lines) - BullMQ email processing, queue monitoring, retry logic \u2705 postal-codes.md (151 lines) - Postal code geocoding cache

\u274c call-tracking.md - Phone call tracking (not yet implemented in codebase)

"},{"location":"v2/features/COMPLETION_STATUS/#core-features-11","title":"Core Features (1/1)","text":"

\u2705 index.md (155 lines) - Features documentation index with navigation

"},{"location":"v2/features/COMPLETION_STATUS/#remaining-files-2026","title":"Remaining Files (20/26)","text":""},{"location":"v2/features/COMPLETION_STATUS/#map-features-09","title":"Map Features (0/9)","text":"

\u274c map/locations.md - Location management (building + unit architecture, NAR integration, CSV import/export) \u274c map/geocoding.md - Multi-provider geocoding (6 providers, fallback chain, confidence scoring) \u274c map/cuts.md - Geographic polygon overlays (GeoJSON storage, point-in-polygon, drawing mode) \u274c map/shifts.md - Volunteer shift management (signup workflow, email notifications) \u274c map/canvassing.md - Canvassing session system (visit outcomes, walking routes, GPS tracking) \u274c map/tracking.md - GPS tracking (breadcrumb trails, route visualization, distance calculation) \u274c map/walk-sheets.md - Printable walk sheets (QR codes, browser print API) \u274c map/data-quality.md - Geocoding quality dashboard (confidence metrics, provider success rates) \u274c map/nar-import.md - NAR 2025 electoral data import (province selector, streaming import, EPSG:3347 projection)

"},{"location":"v2/features/COMPLETION_STATUS/#landing-pages-features-04","title":"Landing Pages Features (0/4)","text":"

\u274c pages/page-builder.md - GrapesJS landing page builder (dual-mode editing, block library) \u274c pages/grapes-editor.md - GrapesJS editor component (forwardRef pattern, error boundary) \u274c pages/block-library.md - Reusable page blocks (6 default blocks, JSON schema) \u274c pages/mkdocs-export.md - MkDocs Material theme export (Jinja2 templates, overrides)

"},{"location":"v2/features/COMPLETION_STATUS/#email-templates-features-04","title":"Email Templates Features (0/4)","text":"

\u274c email-templates/template-system.md - Email template engine (categories, variable interpolation, Handlebars) \u274c email-templates/editor.md - Email template editor (HTML editing, variable insertion, preview) \u274c email-templates/variables.md - Template variable system (required/optional, conditional blocks) \u274c email-templates/versioning.md - Template version history (auto-increment, rollback, change notes)

"},{"location":"v2/features/COMPLETION_STATUS/#media-features-04","title":"Media Features (0/4)","text":"

\u274c media/video-library.md - Video library management (9 directory types, FFprobe metadata) \u274c media/upload.md - Video upload system (automatic metadata extraction, 10GB limit, 7 formats) \u274c media/jobs.md - Media job queue (job types, resource categories, status flow) \u274c media/public-gallery.md - Public video gallery (categories, lock/unlock, reactions, comments)

"},{"location":"v2/features/COMPLETION_STATUS/#newsletter-features-03","title":"Newsletter Features (0/3)","text":"

\u274c newsletter/listmonk-integration.md - Listmonk REST API integration (native fetch client, basic auth) \u274c newsletter/sync.md - Data sync to Listmonk (participants/locations/users \u2192 lists) \u274c newsletter/lists.md - Newsletter list management (results pagination, subscriber attributes)

"},{"location":"v2/features/COMPLETION_STATUS/#tunnel-features-03","title":"Tunnel Features (0/3)","text":"

\u274c tunnel/pangolin-setup.md - Pangolin tunnel configuration (self-hosted API, setup wizard) \u274c tunnel/newt-container.md - Newt Docker integration (nginx dependency, tunnel lifecycle) \u274c tunnel/exit-nodes.md - Tunnel exit node management (routing setup, performance monitoring)

"},{"location":"v2/features/COMPLETION_STATUS/#observability-features-04","title":"Observability Features (0/4)","text":"

\u274c observability/prometheus-metrics.md - Custom metrics collection (12 cm_* metrics, HTTP metrics) \u274c observability/grafana-dashboards.md - Grafana visualization (3 pre-configured dashboards) \u274c observability/alertmanager.md - Alert routing (12 alert rules, notification channels) \u274c observability/data-quality.md - Data quality monitoring (geocoding confidence, validation)

"},{"location":"v2/features/COMPLETION_STATUS/#file-structure-template","title":"File Structure Template","text":"

Each feature file should follow this 12-section structure:

  1. Overview \u2014 Feature purpose, use cases, key capabilities
  2. Architecture \u2014 Mermaid diagram showing frontend \u2192 API \u2192 service \u2192 database flow
  3. Database Models \u2014 Related models with links to database docs
  4. API Endpoints \u2014 List of endpoints with links to API reference docs
  5. Configuration \u2014 Environment variables, settings, feature flags (table format)
  6. Admin Workflow \u2014 Step-by-step guide for administrators
  7. Public Workflow \u2014 Step-by-step guide for public users (if applicable)
  8. Volunteer Workflow \u2014 Step-by-step guide for volunteers (if applicable)
  9. Code Examples \u2014 Real code snippets from backend/frontend
  10. Troubleshooting \u2014 Common issues + solutions
  11. Performance Considerations \u2014 Optimization tips, scaling notes
  12. Related Documentation \u2014 Links to backend modules, frontend pages, database models
"},{"location":"v2/features/COMPLETION_STATUS/#source-references","title":"Source References","text":"

Completed Files Reference:

For Remaining Files:

"},{"location":"v2/features/COMPLETION_STATUS/#statistics","title":"Statistics","text":"

Total Lines Created: ~4,530 lines across 6 files Average File Size: ~755 lines Estimated Remaining: ~15,100 lines (20 files \u00d7 755 avg) Total Target: ~19,630 lines across 26 files

"},{"location":"v2/features/COMPLETION_STATUS/#next-steps","title":"Next Steps","text":"
  1. Create map features (highest priority - core platform functionality)
  2. Create landing pages features (GrapesJS integration)
  3. Create media features (video library + upload)
  4. Create email templates features
  5. Create newsletter features
  6. Create tunnel features
  7. Create observability features
"},{"location":"v2/features/COMPLETION_STATUS/#notes","title":"Notes","text":""},{"location":"v2/features/email-templates/","title":"Email Templates","text":"

The Email Templates feature provides a complete email template management system with variable substitution, versioning, and rich text editing. Create reusable email templates for campaigns, notifications, and communications.

"},{"location":"v2/features/email-templates/#overview","title":"Overview","text":"

The Email Templates system consists of four integrated components:

  1. Template System - Template CRUD and management
  2. Editor - Rich text editor with variable insertion
  3. Variables - Dynamic content placeholders
  4. Versioning - Template version history
"},{"location":"v2/features/email-templates/#features","title":"Features","text":""},{"location":"v2/features/email-templates/#template-management","title":"Template Management","text":""},{"location":"v2/features/email-templates/#rich-text-editor","title":"Rich Text Editor","text":""},{"location":"v2/features/email-templates/#variable-system","title":"Variable System","text":"

Dynamic placeholders:

"},{"location":"v2/features/email-templates/#version-history","title":"Version History","text":""},{"location":"v2/features/email-templates/#user-flow","title":"User Flow","text":""},{"location":"v2/features/email-templates/#admin-experience","title":"Admin Experience","text":"
  1. Create Template (/app/email-templates)
  2. Click \"New Template\"
  3. Enter name and category
  4. Set template type
  5. Save draft

  6. Edit Template (/app/email-templates/:id/edit)

  7. Full-screen rich text editor
  8. Insert variables from dropdown
  9. Preview with sample data
  10. Save changes

  11. Use Template

  12. Select template in campaign form
  13. Variables auto-populated from context
  14. Send email with processed template

  15. Manage Versions (/app/email-templates/:id/versions)

  16. View version history
  17. Compare versions
  18. Restore previous version
"},{"location":"v2/features/email-templates/#architecture","title":"Architecture","text":""},{"location":"v2/features/email-templates/#backend-components","title":"Backend Components","text":"

Module: - api/src/modules/email-templates/email-templates.routes.ts - Template CRUD - api/src/modules/email-templates/email-templates.service.ts - Business logic - api/src/modules/email-templates/email-templates.schemas.ts - Zod validation

Database Models: - EmailTemplate - Template definitions (name, content, variables) - EmailTemplateVersion - Version history (future)

Email Processing: - Variable substitution in email.service.ts - Mustache-style templating: {{variable}} - HTML escaping for security

"},{"location":"v2/features/email-templates/#frontend-components","title":"Frontend Components","text":"

Admin Pages: - admin/src/pages/EmailTemplatesPage.tsx - Template management table - admin/src/pages/EmailTemplateEditorPage.tsx - Full-screen editor

Editor Components: - admin/src/components/email-templates/TemplateEditor.tsx - Rich text editor - admin/src/components/email-templates/VariableInserter.tsx - Variable dropdown

"},{"location":"v2/features/email-templates/#configuration","title":"Configuration","text":""},{"location":"v2/features/email-templates/#template-types","title":"Template Types","text":""},{"location":"v2/features/email-templates/#template-categories","title":"Template Categories","text":""},{"location":"v2/features/email-templates/#variable-system_1","title":"Variable System","text":""},{"location":"v2/features/email-templates/#available-variables","title":"Available Variables","text":"

User Context:

{{user.id}}           # User ID\n{{user.email}}        # Email address\n{{user.name}}         # Full name\n{{user.role}}         # User role\n

Campaign Context:

{{campaign.id}}       # Campaign ID\n{{campaign.name}}     # Campaign name\n{{campaign.description}} # Description\n{{campaign.emailTemplate}} # Email body\n

Representative Context:

{{rep.name}}          # Representative name\n{{rep.title}}         # Title (MP, MLA, etc.)\n{{rep.email}}         # Email address\n{{rep.phone}}         # Phone number\n{{rep.district}}      # District name\n

System Context:

{{site.name}}         # Site name\n{{site.url}}          # Site URL\n{{current.date}}      # Current date\n{{current.year}}      # Current year\n

"},{"location":"v2/features/email-templates/#variable-insertion","title":"Variable Insertion","text":"
// Insert variable at cursor position\neditor.insertContent('{{user.name}}');\n\n// Variable dropdown menu\n<Select>\n  <Option value=\"{{user.name}}\">User Name</Option>\n  <Option value=\"{{user.email}}\">User Email</Option>\n  <Option value=\"{{rep.name}}\">Representative Name</Option>\n</Select>\n
"},{"location":"v2/features/email-templates/#variable-processing","title":"Variable Processing","text":"

Server-side processing in email.service.ts:

function processTemplate(\n  template: string,\n  variables: Record<string, any>\n): string {\n  let processed = template;\n\n  for (const [key, value] of Object.entries(variables)) {\n    const placeholder = `{{${key}}}`;\n    processed = processed.replace(\n      new RegExp(placeholder, 'g'),\n      escapeHtml(String(value))\n    );\n  }\n\n  return processed;\n}\n
"},{"location":"v2/features/email-templates/#database-schema","title":"Database Schema","text":""},{"location":"v2/features/email-templates/#emailtemplate-model","title":"EmailTemplate Model","text":"
model EmailTemplate {\n  id          Int      @id @default(autoincrement())\n  name        String\n  subject     String\n  body        String   @db.Text\n  category    String?\n  type        String   @default(\"custom\")\n  variables   Json?    # Available variables\n  published   Boolean  @default(false)\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n}\n
"},{"location":"v2/features/email-templates/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/email-templates/#admin-endpoints","title":"Admin Endpoints","text":"
GET    /api/email-templates            # List templates\nPOST   /api/email-templates            # Create template\nGET    /api/email-templates/:id        # Get template\nPATCH  /api/email-templates/:id        # Update template\nDELETE /api/email-templates/:id        # Delete template\nPOST   /api/email-templates/:id/clone  # Clone template\nGET    /api/email-templates/:id/preview # Preview with sample data\n
"},{"location":"v2/features/email-templates/#security","title":"Security","text":""},{"location":"v2/features/email-templates/#html-escaping","title":"HTML Escaping","text":"

All variable values are HTML-escaped to prevent XSS:

import { escapeHtml } from '../utils/sanitize';\n\nconst safe = escapeHtml(userInput);\n// Converts: < > & \" ' to HTML entities\n
"},{"location":"v2/features/email-templates/#template-validation","title":"Template Validation","text":""},{"location":"v2/features/email-templates/#best-practices","title":"Best Practices","text":""},{"location":"v2/features/email-templates/#template-design","title":"Template Design","text":""},{"location":"v2/features/email-templates/#variable-usage","title":"Variable Usage","text":""},{"location":"v2/features/email-templates/#version-management","title":"Version Management","text":""},{"location":"v2/features/email-templates/#desktop-only-editor","title":"Desktop-Only Editor","text":"

Email template editor requires desktop browser:

const screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n\nif (isMobile) {\n  return (\n    <Alert\n      message=\"Desktop Required\"\n      description=\"Email editor requires desktop browser\"\n      type=\"warning\"\n    />\n  );\n}\n
"},{"location":"v2/features/email-templates/#integration-points","title":"Integration Points","text":""},{"location":"v2/features/email-templates/#campaign-emails","title":"Campaign Emails","text":"

Campaign emails use templates:

// Select template in campaign form\n<Select>\n  {templates.map(t => (\n    <Option value={t.id}>{t.name}</Option>\n  ))}\n</Select>\n\n// Process template with campaign data\nconst emailBody = processTemplate(template.body, {\n  'user.name': user.name,\n  'campaign.name': campaign.name,\n  'rep.name': representative.name,\n});\n
"},{"location":"v2/features/email-templates/#system-emails","title":"System Emails","text":"

System emails (verification, password reset):

// Load system template\nconst template = await getTemplateByType('email-verification');\n\n// Process with user data\nconst emailBody = processTemplate(template.body, {\n  'user.name': user.name,\n  'verify.link': verificationUrl,\n});\n\n// Send email\nawait emailService.sendEmail({\n  to: user.email,\n  subject: template.subject,\n  html: emailBody,\n});\n
"},{"location":"v2/features/email-templates/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/email-templates/editor/","title":"Email Template Editor","text":""},{"location":"v2/features/email-templates/editor/#overview","title":"Overview","text":"

The Email Template Editor provides a powerful interface for creating and modifying email templates with live preview, variable insertion, and test send functionality. It supports split-pane editing for HTML and plain text versions, visual variable insertion, and real-time rendering with sample data.

Key Features:

Access Control: - Role Required: SUPER_ADMIN only - Route: /app/email-templates/:id/edit - Layout: Full-screen (no AppLayout sidebar)

"},{"location":"v2/features/email-templates/editor/#architecture","title":"Architecture","text":"
flowchart TB\n    subgraph \"Editor UI Components\"\n        Editor[EmailTemplateEditorPage]\n        Toolbar[Editor Toolbar]\n        HtmlEditor[HTML Editor Pane]\n        TextEditor[Text Editor Pane]\n        VarPanel[Variable Insertion Panel]\n        Preview[Live Preview Pane]\n        TestForm[Test Send Form]\n    end\n\n    subgraph \"State Management\"\n        State[Component State]\n        Draft[LocalStorage Draft]\n        AutoSave[Auto-Save Timer]\n    end\n\n    subgraph \"API Layer\"\n        GetTemplate[GET /api/email-templates/:id]\n        UpdateTemplate[PUT /api/email-templates/:id]\n        TestSend[POST /api/email-templates/:id/test]\n    end\n\n    subgraph \"Backend Processing\"\n        Template[(EmailTemplate)]\n        Variables[(EmailTemplateVariable)]\n        Handlebars[Handlebars Compiler]\n        EmailService[Email Service]\n        TestLog[(EmailTemplateTestLog)]\n    end\n\n    Editor --> Toolbar\n    Editor --> HtmlEditor\n    Editor --> TextEditor\n    Editor --> VarPanel\n    Editor --> Preview\n    Editor --> TestForm\n\n    Editor --> State\n    State --> Draft\n    State --> AutoSave\n\n    Editor -->|Load| GetTemplate\n    GetTemplate --> Template\n    GetTemplate --> Variables\n\n    VarPanel -->|Insert| HtmlEditor\n    VarPanel -->|Insert| TextEditor\n\n    HtmlEditor -->|Debounce 300ms| Preview\n    TextEditor --> State\n\n    Preview --> Handlebars\n    Handlebars -->|Render HTML| Preview\n\n    Toolbar -->|Save Click| UpdateTemplate\n    UpdateTemplate --> Template\n    UpdateTemplate -->|Create Version| Versions[(EmailTemplateVersion)]\n\n    TestForm --> TestSend\n    TestSend --> EmailService\n    EmailService -->|Send| SMTP[Nodemailer]\n    SMTP --> TestLog\n\n    AutoSave --> Draft\n\n    style Editor fill:#4a90e2,color:#fff\n    style Template fill:#50c878,color:#fff\n    style Preview fill:#ffb347,color:#333

Data Flow:

  1. Load Template \u2014 Fetch template + variables via GET API
  2. Restore Draft \u2014 Load from localStorage if exists (unsaved changes)
  3. Edit Content \u2014 Type in HTML/text editors, updates component state
  4. Insert Variable \u2014 Click variable button \u2192 inserts {{VAR}} at cursor
  5. Preview Update \u2014 Debounced (300ms) Handlebars compilation + iframe render
  6. Test Send \u2014 Enter recipient + sample data \u2192 POST to test endpoint \u2192 email sent
  7. Save Template \u2014 Click save \u2192 PUT API \u2192 create version \u2192 clear draft \u2192 redirect
  8. Auto-Save Draft \u2014 Blur event \u2192 save to localStorage (not database)
"},{"location":"v2/features/email-templates/editor/#editor-components","title":"Editor Components","text":""},{"location":"v2/features/email-templates/editor/#toolbar","title":"Toolbar","text":"

Location: Top bar (sticky)

Elements: - Template Name \u2014 Read-only display (left) - Save Button \u2014 Saves changes and creates version (right) - Preview Toggle \u2014 Show/hide live preview pane (right) - Test Send Button \u2014 Opens test send modal (right) - Back Button \u2014 Returns to EmailTemplatesPage (left)

Actions:

const handleSave = async () => {\n  setSaving(true);\n  try {\n    await api.put(`/api/email-templates/${id}`, {\n      subjectLine,\n      htmlContent,\n      textContent,\n      changeNotes,\n    });\n\n    message.success('Template saved successfully');\n    localStorage.removeItem(`email-template-draft-${id}`);\n    navigate('/app/email-templates');\n  } catch (error) {\n    message.error('Failed to save template');\n  } finally {\n    setSaving(false);\n  }\n};\n

"},{"location":"v2/features/email-templates/editor/#html-editor-pane","title":"HTML Editor Pane","text":"

Location: Left side (50% width) or full width when preview hidden

Features: - Textarea or Monaco Editor \u2014 Syntax highlighting (Monaco upgrade path) - Line Numbers \u2014 Visual line number gutter - Auto-Resize \u2014 Grows to fit content (max 80vh) - Tab Support \u2014 Tab key inserts 2 spaces (not focus change)

Implementation:

const [htmlContent, setHtmlContent] = useState('');\nconst htmlEditorRef = useRef<HTMLTextAreaElement>(null);\n\nconst handleHtmlChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n  setHtmlContent(e.target.value);\n  debouncedPreview(e.target.value, sampleData);\n};\n\nconst handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n  // Tab key support\n  if (e.key === 'Tab') {\n    e.preventDefault();\n    const textarea = e.currentTarget;\n    const start = textarea.selectionStart;\n    const end = textarea.selectionEnd;\n\n    setHtmlContent(\n      htmlContent.substring(0, start) + '  ' + htmlContent.substring(end)\n    );\n\n    setTimeout(() => {\n      textarea.selectionStart = textarea.selectionEnd = start + 2;\n    }, 0);\n  }\n};\n

"},{"location":"v2/features/email-templates/editor/#text-editor-pane","title":"Text Editor Pane","text":"

Location: Left side (50% width) or full width when preview hidden

Features: - Plain Text Editing \u2014 No syntax highlighting needed - Auto-Resize \u2014 Matches HTML editor height - Variable Insertion \u2014 Same insertion panel as HTML editor

Implementation:

const [textContent, setTextContent] = useState('');\nconst textEditorRef = useRef<HTMLTextAreaElement>(null);\n\nconst handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n  setTextContent(e.target.value);\n};\n

"},{"location":"v2/features/email-templates/editor/#variable-insertion-panel","title":"Variable Insertion Panel","text":"

Location: Right sidebar (collapsible)

Features: - Variable List \u2014 All template variables with labels - Insert Buttons \u2014 Click to insert {{VAR}} at cursor - Required Badge \u2014 Red badge for required variables - Conditional Badge \u2014 Blue badge for conditional variables - Sample Value Display \u2014 Shows example value below each variable - Search/Filter \u2014 Filter variables by name (if many variables)

Implementation:

const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {\n  const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;\n  if (!textarea) return;\n\n  const start = textarea.selectionStart;\n  const end = textarea.selectionEnd;\n  const content = editorType === 'html' ? htmlContent : textContent;\n\n  const before = content.substring(0, start);\n  const after = content.substring(end);\n  const newContent = before + `{{${variableKey}}}` + after;\n\n  if (editorType === 'html') {\n    setHtmlContent(newContent);\n  } else {\n    setTextContent(newContent);\n  }\n\n  // Move cursor after inserted variable\n  setTimeout(() => {\n    const newPos = start + variableKey.length + 4; // 4 = {{ + }}\n    textarea.selectionStart = newPos;\n    textarea.selectionEnd = newPos;\n    textarea.focus();\n  }, 0);\n};\n

Variable List UI:

<Space direction=\"vertical\" style={{ width: '100%' }}>\n  {variables\n    .sort((a, b) => a.sortOrder - b.sortOrder)\n    .map((variable) => (\n      <Card key={variable.id} size=\"small\">\n        <Space direction=\"vertical\" size={0} style={{ width: '100%' }}>\n          <Space>\n            <Text strong>{variable.label}</Text>\n            {variable.isRequired && <Tag color=\"red\">Required</Tag>}\n            {variable.isConditional && <Tag color=\"blue\">Conditional</Tag>}\n          </Space>\n\n          <Text type=\"secondary\" style={{ fontSize: 12 }}>\n            {variable.description}\n          </Text>\n\n          {variable.sampleValue && (\n            <Text code style={{ fontSize: 11 }}>\n              Example: {variable.sampleValue}\n            </Text>\n          )}\n\n          <Space size=\"small\">\n            <Button\n              size=\"small\"\n              onClick={() => handleInsertVariable(variable.key, 'html')}\n            >\n              Insert to HTML\n            </Button>\n            <Button\n              size=\"small\"\n              onClick={() => handleInsertVariable(variable.key, 'text')}\n            >\n              Insert to Text\n            </Button>\n          </Space>\n        </Space>\n      </Card>\n    ))}\n</Space>\n

"},{"location":"v2/features/email-templates/editor/#live-preview-pane","title":"Live Preview Pane","text":"

Location: Right side (50% width) when enabled

Features: - Iframe Rendering \u2014 Isolated HTML preview - Sample Data Form \u2014 Edit sample variable values - Desktop/Mobile Toggle \u2014 Preview in different viewport sizes - Debounced Updates \u2014 Renders 300ms after typing stops - Error Display \u2014 Shows Handlebars compilation errors

Implementation:

import Handlebars from 'handlebars';\n\nconst [previewHtml, setPreviewHtml] = useState('');\nconst [sampleData, setSampleData] = useState<Record<string, unknown>>({});\nconst previewRef = useRef<HTMLIFrameElement>(null);\n\nconst renderPreview = useCallback((html: string, data: Record<string, unknown>) => {\n  try {\n    const compiled = Handlebars.compile(html);\n    const rendered = compiled(data);\n\n    // Inject into iframe\n    if (previewRef.current?.contentDocument) {\n      const doc = previewRef.current.contentDocument;\n      doc.open();\n      doc.write(`\n        <!DOCTYPE html>\n        <html>\n          <head>\n            <meta charset=\"UTF-8\">\n            <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n            <style>\n              body { font-family: Arial, sans-serif; padding: 20px; }\n            </style>\n          </head>\n          <body>${rendered}</body>\n        </html>\n      `);\n      doc.close();\n    }\n\n    setPreviewHtml(rendered);\n  } catch (error) {\n    console.error('Preview render error:', error);\n    setPreviewError(error.message);\n  }\n}, []);\n\nconst debouncedPreview = useMemo(\n  () => debounce(renderPreview, 300),\n  [renderPreview]\n);\n\n// Update preview when HTML or sample data changes\nuseEffect(() => {\n  debouncedPreview(htmlContent, sampleData);\n}, [htmlContent, sampleData, debouncedPreview]);\n

Sample Data Form:

const handleSampleDataChange = (variableKey: string, value: unknown) => {\n  setSampleData((prev) => ({\n    ...prev,\n    [variableKey]: value,\n  }));\n};\n\n// Render form\n<Space direction=\"vertical\" style={{ width: '100%', marginBottom: 16 }}>\n  <Title level={5}>Sample Data</Title>\n  {variables.map((variable) => (\n    <Form.Item key={variable.id} label={variable.label}>\n      <Input\n        value={sampleData[variable.key] as string || ''}\n        onChange={(e) => handleSampleDataChange(variable.key, e.target.value)}\n        placeholder={variable.sampleValue || ''}\n      />\n    </Form.Item>\n  ))}\n</Space>\n

"},{"location":"v2/features/email-templates/editor/#test-send-form","title":"Test Send Form","text":"

Location: Modal dialog

Features: - Recipient Email Input \u2014 Where to send test email - Sample Data Editor \u2014 JSON editor or form fields - Send Button \u2014 Triggers test send API call - Success/Failure Notification \u2014 Shows send result - Test Log Link \u2014 Link to test send history

Implementation:

const [testModalVisible, setTestModalVisible] = useState(false);\nconst [testRecipient, setTestRecipient] = useState('');\nconst [testData, setTestData] = useState<Record<string, unknown>>({});\n\nconst handleTestSend = async () => {\n  if (!testRecipient) {\n    message.error('Please enter recipient email');\n    return;\n  }\n\n  setTestSending(true);\n  try {\n    await api.post(`/api/email-templates/${id}/test`, {\n      recipientEmail: testRecipient,\n      testData,\n    });\n\n    message.success('Test email sent successfully');\n    setTestModalVisible(false);\n  } catch (error) {\n    message.error('Failed to send test email');\n  } finally {\n    setTestSending(false);\n  }\n};\n\n// Modal UI\n<Modal\n  title=\"Send Test Email\"\n  visible={testModalVisible}\n  onOk={handleTestSend}\n  onCancel={() => setTestModalVisible(false)}\n  confirmLoading={testSending}\n  okText=\"Send Test\"\n>\n  <Form layout=\"vertical\">\n    <Form.Item label=\"Recipient Email\" required>\n      <Input\n        type=\"email\"\n        value={testRecipient}\n        onChange={(e) => setTestRecipient(e.target.value)}\n        placeholder=\"your-email@example.com\"\n      />\n    </Form.Item>\n\n    <Form.Item label=\"Sample Data\">\n      <Space direction=\"vertical\" style={{ width: '100%' }}>\n        {variables.map((variable) => (\n          <Input\n            key={variable.id}\n            addonBefore={variable.label}\n            value={testData[variable.key] as string || ''}\n            onChange={(e) =>\n              setTestData((prev) => ({ ...prev, [variable.key]: e.target.value }))\n            }\n            placeholder={variable.sampleValue || ''}\n          />\n        ))}\n      </Space>\n    </Form.Item>\n  </Form>\n</Modal>\n

"},{"location":"v2/features/email-templates/editor/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/email-templates/editor/#opening-editor","title":"Opening Editor","text":"

From EmailTemplatesPage:

  1. Click template row in table
  2. Opens template detail modal
  3. Click \"Edit\" button in modal
  4. Opens EmailTemplateEditorPage in same tab

Direct URL:

/app/email-templates/{id}/edit\n

Route Definition:

// admin/src/App.tsx\n\n<Route\n  path=\"/app/email-templates/:id/edit\"\n  element={\n    <ProtectedRoute allowedRoles={[SUPER_ADMIN]}>\n      <EmailTemplateEditorPage />\n    </ProtectedRoute>\n  }\n/>\n

"},{"location":"v2/features/email-templates/editor/#editing-html-content","title":"Editing HTML Content","text":"

Step 1: Load Template - Template data fetched via API on component mount - HTML/text content populated in editors - Variables loaded in insertion panel

Step 2: Edit HTML - Type HTML with {{VARIABLES}} placeholders - Use variable insertion buttons for convenience - Preview updates automatically (300ms debounce)

Step 3: Insert Variables - Click variable \"Insert to HTML\" button - {{VARIABLE_KEY}} inserted at cursor position - Cursor moves after inserted variable

Step 4: Preview Changes - Live preview pane shows rendered HTML - Edit sample data to test different values - Check for formatting issues

Example Editing Session:

<!-- Initial HTML -->\n<p>Dear {{USER_NAME}},</p>\n<p>Thank you for signing up.</p>\n\n<!-- Add shift details -->\n<p>Dear {{USER_NAME}},</p>\n<p>Thank you for signing up for <strong>{{SHIFT_TITLE}}</strong>.</p>\n<ul>\n  <li>Date: {{SHIFT_DATE}}</li>\n  <li>Time: {{SHIFT_TIME}}</li>\n</ul>\n\n<!-- Add conditional phone -->\n<p>Dear {{USER_NAME}},</p>\n<p>Thank you for signing up for <strong>{{SHIFT_TITLE}}</strong>.</p>\n<ul>\n  <li>Date: {{SHIFT_DATE}}</li>\n  <li>Time: {{SHIFT_TIME}}</li>\n</ul>\n\n{{#if HAS_PHONE}}\n<p>We'll call you at {{USER_PHONE}} if there are any changes.</p>\n{{/if}}\n

"},{"location":"v2/features/email-templates/editor/#using-variable-insertion","title":"Using Variable Insertion","text":"

Keyboard Method: 1. Type {{ in HTML editor 2. Type variable name (e.g., USER_NAME) 3. Type }}

Button Method: 1. Place cursor where you want variable 2. Click variable \"Insert to HTML\" button 3. {{VARIABLE_KEY}} inserted at cursor 4. Cursor moves to end of insertion

Insertion Logic:

const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {\n  const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;\n  if (!textarea) return;\n\n  const start = textarea.selectionStart;\n  const end = textarea.selectionEnd;\n  const content = editorType === 'html' ? htmlContent : textContent;\n\n  // Replace selection with variable\n  const before = content.substring(0, start);\n  const after = content.substring(end);\n  const variable = `{{${variableKey}}}`;\n  const newContent = before + variable + after;\n\n  // Update state\n  if (editorType === 'html') {\n    setHtmlContent(newContent);\n  } else {\n    setTextContent(newContent);\n  }\n\n  // Move cursor to end of inserted variable\n  setTimeout(() => {\n    const newPos = start + variable.length;\n    textarea.selectionStart = newPos;\n    textarea.selectionEnd = newPos;\n    textarea.focus();\n  }, 0);\n};\n

"},{"location":"v2/features/email-templates/editor/#live-preview","title":"Live Preview","text":"

Preview Update Flow:

  1. Type in HTML Editor
  2. onChange event fires
  3. Updates htmlContent state
  4. Triggers debounced preview render (300ms)

  5. Debounced Render

  6. Waits 300ms after typing stops
  7. Compiles Handlebars template
  8. Interpolates with sample data
  9. Injects HTML into iframe

  10. Sample Data Changes

  11. Edit sample data form fields
  12. Updates sampleData state
  13. Immediately triggers preview render (no debounce)

Preview Error Handling:

const renderPreview = (html: string, data: Record<string, unknown>) => {\n  try {\n    const compiled = Handlebars.compile(html);\n    const rendered = compiled(data);\n\n    // Inject into iframe...\n    setPreviewError(null);\n  } catch (error) {\n    // Show error in preview pane\n    setPreviewError(error.message);\n\n    if (previewRef.current?.contentDocument) {\n      const doc = previewRef.current.contentDocument;\n      doc.open();\n      doc.write(`\n        <div style=\"color: red; padding: 20px;\">\n          <h3>Preview Error</h3>\n          <pre>${error.message}</pre>\n        </div>\n      `);\n      doc.close();\n    }\n  }\n};\n

"},{"location":"v2/features/email-templates/editor/#testing-template","title":"Testing Template","text":"

Step 1: Click \"Send Test\" Button - Opens test send modal

Step 2: Enter Recipient Email - Your email address (or test account) - Validates email format before sending

Step 3: Edit Sample Data - Pre-filled with variable sample values - Modify to test specific scenarios - Example: Set HAS_PHONE to false to test conditional block

Step 4: Click \"Send Test\" - POST request to /api/email-templates/:id/test - Email sent via SMTP (or MailHog in test mode) - Success notification displayed

Step 5: Check Email - Open email client (or MailHog at http://localhost:8025) - Verify rendering, variables, formatting - Test links, images, layout

Step 6: Review Test Log - Navigate to \"Test Logs\" tab in template detail modal - See test send history (recipient, timestamp, success/failure) - Debug errors if send failed

"},{"location":"v2/features/email-templates/editor/#saving-changes","title":"Saving Changes","text":"

Step 1: Click \"Save\" Button - Toolbar save button (or Ctrl+S keyboard shortcut)

Step 2: Enter Change Notes - Modal prompts for change description - Used for version history audit trail - Optional but recommended

Step 3: Confirm Save - PUT request to /api/email-templates/:id - Creates new version automatically - Clears localStorage draft - Redirects to EmailTemplatesPage

Save Implementation:

const [saveModalVisible, setSaveModalVisible] = useState(false);\nconst [changeNotes, setChangeNotes] = useState('');\n\nconst handleSave = async () => {\n  setSaving(true);\n  try {\n    await api.put(`/api/email-templates/${id}`, {\n      subjectLine,\n      htmlContent,\n      textContent,\n      changeNotes: changeNotes || undefined,\n    });\n\n    message.success('Template saved successfully');\n\n    // Clear draft\n    localStorage.removeItem(`email-template-draft-${id}`);\n\n    // Redirect\n    navigate('/app/email-templates');\n  } catch (error) {\n    message.error('Failed to save template');\n  } finally {\n    setSaving(false);\n    setSaveModalVisible(false);\n  }\n};\n\n// Keyboard shortcut\nuseEffect(() => {\n  const handleKeyDown = (e: KeyboardEvent) => {\n    if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n      e.preventDefault();\n      setSaveModalVisible(true);\n    }\n  };\n\n  window.addEventListener('keydown', handleKeyDown);\n  return () => window.removeEventListener('keydown', handleKeyDown);\n}, []);\n

"},{"location":"v2/features/email-templates/editor/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/email-templates/editor/#emailtemplateeditorpage-component","title":"EmailTemplateEditorPage Component","text":"

Full Component Structure:

// admin/src/pages/EmailTemplateEditorPage.tsx\n\nimport React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';\nimport { useParams, useNavigate } from 'react-router-dom';\nimport { Button, Input, Space, Card, Tag, Typography, Modal, Form, message } from 'antd';\nimport { SaveOutlined, SendOutlined, ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';\nimport Handlebars from 'handlebars';\nimport { debounce } from 'lodash';\nimport { api } from '@/lib/api';\nimport type { EmailTemplate, EmailTemplateVariable } from '@/types/api';\n\nconst { Title, Text } = Typography;\nconst { TextArea } = Input;\n\nexport default function EmailTemplateEditorPage() {\n  const { id } = useParams<{ id: string }>();\n  const navigate = useNavigate();\n\n  // State\n  const [loading, setLoading] = useState(true);\n  const [saving, setSaving] = useState(false);\n  const [template, setTemplate] = useState<EmailTemplate | null>(null);\n  const [variables, setVariables] = useState<EmailTemplateVariable[]>([]);\n\n  const [subjectLine, setSubjectLine] = useState('');\n  const [htmlContent, setHtmlContent] = useState('');\n  const [textContent, setTextContent] = useState('');\n\n  const [showPreview, setShowPreview] = useState(true);\n  const [sampleData, setSampleData] = useState<Record<string, unknown>>({});\n  const [previewError, setPreviewError] = useState<string | null>(null);\n\n  const [testModalVisible, setTestModalVisible] = useState(false);\n  const [testRecipient, setTestRecipient] = useState('');\n  const [testSending, setTestSending] = useState(false);\n\n  const [saveModalVisible, setSaveModalVisible] = useState(false);\n  const [changeNotes, setChangeNotes] = useState('');\n\n  // Refs\n  const htmlEditorRef = useRef<HTMLTextAreaElement>(null);\n  const textEditorRef = useRef<HTMLTextAreaElement>(null);\n  const previewRef = useRef<HTMLIFrameElement>(null);\n\n  // Load template\n  useEffect(() => {\n    const loadTemplate = async () => {\n      try {\n        const response = await api.get(`/api/email-templates/${id}`);\n        const { template: tmpl, variables: vars } = response.data;\n\n        setTemplate(tmpl);\n        setVariables(vars);\n\n        setSubjectLine(tmpl.subjectLine);\n        setHtmlContent(tmpl.htmlContent);\n        setTextContent(tmpl.textContent);\n\n        // Initialize sample data from variable sample values\n        const initialSampleData: Record<string, unknown> = {};\n        vars.forEach((v: EmailTemplateVariable) => {\n          if (v.sampleValue) {\n            initialSampleData[v.key] = v.sampleValue;\n          }\n        });\n        setSampleData(initialSampleData);\n\n        // Restore draft if exists\n        const draft = localStorage.getItem(`email-template-draft-${id}`);\n        if (draft) {\n          const { subjectLine: draftSubject, htmlContent: draftHtml, textContent: draftText } = JSON.parse(draft);\n          setSubjectLine(draftSubject);\n          setHtmlContent(draftHtml);\n          setTextContent(draftText);\n          message.info('Restored unsaved changes from draft');\n        }\n\n        setLoading(false);\n      } catch (error) {\n        message.error('Failed to load template');\n        navigate('/app/email-templates');\n      }\n    };\n\n    loadTemplate();\n  }, [id, navigate]);\n\n  // Auto-save draft to localStorage\n  useEffect(() => {\n    if (!loading && template) {\n      const draft = {\n        subjectLine,\n        htmlContent,\n        textContent,\n      };\n      localStorage.setItem(`email-template-draft-${id}`, JSON.stringify(draft));\n    }\n  }, [subjectLine, htmlContent, textContent, loading, template, id]);\n\n  // Preview rendering\n  const renderPreview = useCallback((html: string, data: Record<string, unknown>) => {\n    try {\n      const compiled = Handlebars.compile(html);\n      const rendered = compiled(data);\n\n      if (previewRef.current?.contentDocument) {\n        const doc = previewRef.current.contentDocument;\n        doc.open();\n        doc.write(`\n          <!DOCTYPE html>\n          <html>\n            <head>\n              <meta charset=\"UTF-8\">\n              <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n              <style>\n                body {\n                  font-family: Arial, sans-serif;\n                  padding: 20px;\n                  line-height: 1.6;\n                }\n              </style>\n            </head>\n            <body>${rendered}</body>\n          </html>\n        `);\n        doc.close();\n      }\n\n      setPreviewError(null);\n    } catch (error: any) {\n      setPreviewError(error.message);\n\n      if (previewRef.current?.contentDocument) {\n        const doc = previewRef.current.contentDocument;\n        doc.open();\n        doc.write(`\n          <div style=\"color: red; padding: 20px;\">\n            <h3>Preview Error</h3>\n            <pre>${error.message}</pre>\n          </div>\n        `);\n        doc.close();\n      }\n    }\n  }, []);\n\n  // Debounced preview\n  const debouncedPreview = useMemo(\n    () => debounce(renderPreview, 300),\n    [renderPreview]\n  );\n\n  // Update preview when HTML or sample data changes\n  useEffect(() => {\n    if (showPreview) {\n      debouncedPreview(htmlContent, sampleData);\n    }\n  }, [htmlContent, sampleData, showPreview, debouncedPreview]);\n\n  // Variable insertion\n  const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {\n    const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;\n    if (!textarea) return;\n\n    const start = textarea.selectionStart;\n    const end = textarea.selectionEnd;\n    const content = editorType === 'html' ? htmlContent : textContent;\n\n    const before = content.substring(0, start);\n    const after = content.substring(end);\n    const variable = `{{${variableKey}}}`;\n    const newContent = before + variable + after;\n\n    if (editorType === 'html') {\n      setHtmlContent(newContent);\n    } else {\n      setTextContent(newContent);\n    }\n\n    setTimeout(() => {\n      const newPos = start + variable.length;\n      textarea.selectionStart = newPos;\n      textarea.selectionEnd = newPos;\n      textarea.focus();\n    }, 0);\n  };\n\n  // Save template\n  const handleSave = async () => {\n    setSaving(true);\n    try {\n      await api.put(`/api/email-templates/${id}`, {\n        subjectLine,\n        htmlContent,\n        textContent,\n        changeNotes: changeNotes || undefined,\n      });\n\n      message.success('Template saved successfully');\n      localStorage.removeItem(`email-template-draft-${id}`);\n      navigate('/app/email-templates');\n    } catch (error) {\n      message.error('Failed to save template');\n    } finally {\n      setSaving(false);\n      setSaveModalVisible(false);\n    }\n  };\n\n  // Test send\n  const handleTestSend = async () => {\n    if (!testRecipient) {\n      message.error('Please enter recipient email');\n      return;\n    }\n\n    setTestSending(true);\n    try {\n      await api.post(`/api/email-templates/${id}/test`, {\n        recipientEmail: testRecipient,\n        testData: sampleData,\n      });\n\n      message.success('Test email sent successfully');\n      setTestModalVisible(false);\n    } catch (error) {\n      message.error('Failed to send test email');\n    } finally {\n      setTestSending(false);\n    }\n  };\n\n  // Keyboard shortcuts\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n        e.preventDefault();\n        setSaveModalVisible(true);\n      }\n      if ((e.ctrlKey || e.metaKey) && e.key === 'p') {\n        e.preventDefault();\n        setShowPreview(!showPreview);\n      }\n    };\n\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [showPreview]);\n\n  if (loading) {\n    return <div style={{ padding: 24 }}>Loading...</div>;\n  }\n\n  return (\n    <div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>\n      {/* Toolbar */}\n      <div\n        style={{\n          padding: '12px 24px',\n          borderBottom: '1px solid #f0f0f0',\n          display: 'flex',\n          justifyContent: 'space-between',\n          alignItems: 'center',\n        }}\n      >\n        <Space>\n          <Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/app/email-templates')}>\n            Back\n          </Button>\n          <Title level={4} style={{ margin: 0 }}>\n            {template?.name}\n          </Title>\n        </Space>\n\n        <Space>\n          <Button icon={<EyeOutlined />} onClick={() => setShowPreview(!showPreview)}>\n            {showPreview ? 'Hide' : 'Show'} Preview\n          </Button>\n          <Button icon={<SendOutlined />} onClick={() => setTestModalVisible(true)}>\n            Send Test\n          </Button>\n          <Button type=\"primary\" icon={<SaveOutlined />} onClick={() => setSaveModalVisible(true)}>\n            Save\n          </Button>\n        </Space>\n      </div>\n\n      {/* Editor Area */}\n      <div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>\n        {/* Left: Editors */}\n        <div\n          style={{\n            flex: showPreview ? 1 : 2,\n            padding: 24,\n            overflowY: 'auto',\n            borderRight: '1px solid #f0f0f0',\n          }}\n        >\n          <Space direction=\"vertical\" style={{ width: '100%' }} size=\"large\">\n            {/* Subject Line */}\n            <div>\n              <Text strong>Subject Line</Text>\n              <Input\n                value={subjectLine}\n                onChange={(e) => setSubjectLine(e.target.value)}\n                placeholder=\"Enter subject line with {{VARIABLES}}\"\n              />\n            </div>\n\n            {/* HTML Editor */}\n            <div>\n              <Text strong>HTML Content</Text>\n              <TextArea\n                ref={htmlEditorRef}\n                value={htmlContent}\n                onChange={(e) => setHtmlContent(e.target.value)}\n                placeholder=\"Enter HTML content with {{VARIABLES}}\"\n                rows={20}\n                style={{ fontFamily: 'monospace', fontSize: 13 }}\n              />\n            </div>\n\n            {/* Text Editor */}\n            <div>\n              <Text strong>Plain Text Content</Text>\n              <TextArea\n                ref={textEditorRef}\n                value={textContent}\n                onChange={(e) => setTextContent(e.target.value)}\n                placeholder=\"Enter plain text version\"\n                rows={15}\n                style={{ fontFamily: 'monospace', fontSize: 13 }}\n              />\n            </div>\n          </Space>\n        </div>\n\n        {/* Right: Preview + Variables */}\n        {showPreview && (\n          <div style={{ flex: 1, padding: 24, overflowY: 'auto' }}>\n            <Space direction=\"vertical\" style={{ width: '100%' }} size=\"large\">\n              {/* Variables Panel */}\n              <Card title=\"Variables\" size=\"small\">\n                <Space direction=\"vertical\" style={{ width: '100%' }} size=\"small\">\n                  {variables\n                    .sort((a, b) => a.sortOrder - b.sortOrder)\n                    .map((variable) => (\n                      <Card key={variable.id} size=\"small\" style={{ marginBottom: 8 }}>\n                        <Space direction=\"vertical\" size={4} style={{ width: '100%' }}>\n                          <Space>\n                            <Text strong>{variable.label}</Text>\n                            {variable.isRequired && <Tag color=\"red\">Required</Tag>}\n                            {variable.isConditional && <Tag color=\"blue\">Conditional</Tag>}\n                          </Space>\n\n                          {variable.description && (\n                            <Text type=\"secondary\" style={{ fontSize: 12 }}>\n                              {variable.description}\n                            </Text>\n                          )}\n\n                          <Space size=\"small\">\n                            <Button size=\"small\" onClick={() => handleInsertVariable(variable.key, 'html')}>\n                              Insert to HTML\n                            </Button>\n                            <Button size=\"small\" onClick={() => handleInsertVariable(variable.key, 'text')}>\n                              Insert to Text\n                            </Button>\n                          </Space>\n                        </Space>\n                      </Card>\n                    ))}\n                </Space>\n              </Card>\n\n              {/* Preview */}\n              <Card title=\"Live Preview\" size=\"small\">\n                {previewError && (\n                  <div style={{ color: 'red', marginBottom: 12 }}>\n                    <Text strong>Error:</Text> {previewError}\n                  </div>\n                )}\n\n                <iframe\n                  ref={previewRef}\n                  style={{\n                    width: '100%',\n                    height: 600,\n                    border: '1px solid #d9d9d9',\n                    borderRadius: 4,\n                  }}\n                  title=\"Email Preview\"\n                />\n              </Card>\n            </Space>\n          </div>\n        )}\n      </div>\n\n      {/* Save Modal */}\n      <Modal\n        title=\"Save Template\"\n        visible={saveModalVisible}\n        onOk={handleSave}\n        onCancel={() => setSaveModalVisible(false)}\n        confirmLoading={saving}\n        okText=\"Save\"\n      >\n        <Form layout=\"vertical\">\n          <Form.Item label=\"Change Notes (optional)\">\n            <TextArea\n              value={changeNotes}\n              onChange={(e) => setChangeNotes(e.target.value)}\n              placeholder=\"Describe what changed in this version\"\n              rows={4}\n            />\n          </Form.Item>\n        </Form>\n      </Modal>\n\n      {/* Test Send Modal */}\n      <Modal\n        title=\"Send Test Email\"\n        visible={testModalVisible}\n        onOk={handleTestSend}\n        onCancel={() => setTestModalVisible(false)}\n        confirmLoading={testSending}\n        okText=\"Send Test\"\n      >\n        <Form layout=\"vertical\">\n          <Form.Item label=\"Recipient Email\" required>\n            <Input\n              type=\"email\"\n              value={testRecipient}\n              onChange={(e) => setTestRecipient(e.target.value)}\n              placeholder=\"your-email@example.com\"\n            />\n          </Form.Item>\n\n          <Form.Item label=\"Sample Data\">\n            <Text type=\"secondary\" style={{ display: 'block', marginBottom: 8 }}>\n              Using sample data from preview. Edit values in the preview panel to change test data.\n            </Text>\n            <pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4 }}>\n              {JSON.stringify(sampleData, null, 2)}\n            </pre>\n          </Form.Item>\n        </Form>\n      </Modal>\n    </div>\n  );\n}\n
"},{"location":"v2/features/email-templates/editor/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/email-templates/editor/#problem-preview-not-updating","title":"Problem: Preview not updating","text":"

Symptoms: - Type in HTML editor but preview doesn't change - Preview shows old content

Causes: 1. Debounce timer still running (300ms delay) 2. Handlebars compilation error (silent failure) 3. Iframe not re-rendering

Solutions:

Wait for debounce: - Wait 300ms after typing stops - Preview should update automatically

Check browser console:

// Look for errors\nHandlebars.compile error: ...\n

Force preview update:

// Add button to manually trigger preview\n<Button onClick={() => renderPreview(htmlContent, sampleData)}>\n  Refresh Preview\n</Button>\n

Check iframe contentDocument:

console.log('Iframe doc:', previewRef.current?.contentDocument);\n// Should not be null\n

"},{"location":"v2/features/email-templates/editor/#problem-test-send-fails","title":"Problem: Test send fails","text":"

Symptoms: - \"Failed to send test email\" error - Email not received in inbox or MailHog

Causes: 1. SMTP configuration incorrect 2. Email test mode disabled (sending to real SMTP) 3. Recipient email invalid 4. Template has compilation errors

Solutions:

Check SMTP settings:

# .env\nEMAIL_TEST_MODE=true  # Use MailHog\n

Verify MailHog running:

docker compose ps mailhog\n# Should show \"Up\"\n

Check test logs:

SELECT * FROM email_template_test_logs\nWHERE template_id = 'xxx'\nORDER BY created_at DESC\nLIMIT 5;\n\n-- Look at error_message column\n

Test with minimal template:

<p>Hello {{USER_NAME}}</p>\n

Validate email address:

import validator from 'validator';\n\nif (!validator.isEmail(testRecipient)) {\n  message.error('Invalid email address');\n  return;\n}\n

"},{"location":"v2/features/email-templates/editor/#problem-variable-insertion-doesnt-work","title":"Problem: Variable insertion doesn't work","text":"

Symptoms: - Click \"Insert to HTML\" button but nothing happens - Variable inserted in wrong location

Causes: 1. Textarea ref not set 2. Cursor position not captured correctly 3. State update timing issue

Solutions:

Check ref exists:

console.log('HTML ref:', htmlEditorRef.current);\n// Should be <textarea> element\n

Debug cursor position:

const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {\n  const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;\n  console.log('Cursor position:', textarea?.selectionStart, textarea?.selectionEnd);\n\n  // Rest of insertion logic...\n};\n

Manual workaround: - Type {{VARIABLE_KEY}} manually instead of using button

"},{"location":"v2/features/email-templates/editor/#problem-draft-not-restored-on-reload","title":"Problem: Draft not restored on reload","text":"

Symptoms: - Unsaved changes lost after browser refresh - No \"Restored draft\" message

Causes: 1. localStorage not available (private browsing) 2. Draft key mismatch 3. localStorage quota exceeded

Solutions:

Check localStorage:

// Browser console\nlocalStorage.getItem('email-template-draft-cuid123');\n// Should return JSON string\n

Verify draft key:

console.log('Draft key:', `email-template-draft-${id}`);\n

Clear old drafts:

// Browser console\nfor (let i = 0; i < localStorage.length; i++) {\n  const key = localStorage.key(i);\n  if (key?.startsWith('email-template-draft-')) {\n    localStorage.removeItem(key);\n  }\n}\n

"},{"location":"v2/features/email-templates/editor/#future-enhancements","title":"Future Enhancements","text":""},{"location":"v2/features/email-templates/editor/#monaco-editor-integration","title":"Monaco Editor Integration","text":"

Current: Basic HTML textarea Future: Monaco Editor with syntax highlighting, IntelliSense, error detection

Benefits: - Syntax highlighting for HTML - Auto-completion for HTML tags and Handlebars syntax - Error squiggles for invalid HTML - Multi-cursor editing - Code folding

Implementation:

import Editor from '@monaco-editor/react';\n\n<Editor\n  height=\"600px\"\n  language=\"html\"\n  value={htmlContent}\n  onChange={(value) => setHtmlContent(value || '')}\n  options={{\n    minimap: { enabled: false },\n    lineNumbers: 'on',\n    wordWrap: 'on',\n  }}\n/>\n

"},{"location":"v2/features/email-templates/editor/#drag-drop-block-builder","title":"Drag-Drop Block Builder","text":"

Current: Manual HTML editing Future: Visual block builder (like GrapesJS)

Benefits: - No HTML knowledge required - Pre-built email blocks (header, footer, CTA button) - Drag-drop interface - Mobile-responsive by default

Implementation: - Use GrapesJS (same as landing page editor) - Custom blocks for email-safe components - Export to HTML for template storage

"},{"location":"v2/features/email-templates/editor/#email-client-previews","title":"Email Client Previews","text":"

Current: Single iframe preview Future: Multi-client previews (Gmail, Outlook, Apple Mail)

Benefits: - Test rendering across email clients - Catch client-specific CSS issues - Preview dark mode rendering

Services: - Litmus API integration - Email on Acid screenshots - Self-hosted preview using email client CSS emulation

"},{"location":"v2/features/email-templates/editor/#ab-testing-support","title":"A/B Testing Support","text":"

Current: Single template version Future: A/B testing with variant templates

Features: - Create template variants (A, B, C) - Split traffic across variants - Track open rates, click rates - Auto-promote winning variant

Implementation: - EmailTemplateVariant model (templateId, variantName, weight, stats) - Random variant selection on send - Tracking pixel in email HTML - Analytics dashboard

"},{"location":"v2/features/email-templates/editor/#performance","title":"Performance","text":""},{"location":"v2/features/email-templates/editor/#auto-save-timing","title":"Auto-Save Timing","text":"

Current Implementation: - Save to localStorage on blur (when focus leaves editor) - No automatic interval-based saves

Performance Impact: - Negligible (localStorage write is < 1ms) - No network requests (local only)

Alternative: Interval-Based Auto-Save:

useEffect(() => {\n  const interval = setInterval(() => {\n    if (htmlContent || textContent) {\n      localStorage.setItem(`email-template-draft-${id}`, JSON.stringify({\n        subjectLine,\n        htmlContent,\n        textContent,\n        savedAt: new Date().toISOString(),\n      }));\n    }\n  }, 10000); // Every 10 seconds\n\n  return () => clearInterval(interval);\n}, [id, subjectLine, htmlContent, textContent]);\n

"},{"location":"v2/features/email-templates/editor/#preview-rendering-performance","title":"Preview Rendering Performance","text":"

Debounce Delay: - Current: 300ms - Too short: Preview updates too frequently (distracting) - Too long: Preview feels laggy

Handlebars Compilation: - Fast (< 1ms for typical templates) - May slow down for very large templates (> 100KB)

Iframe Rendering: - Browser-native rendering (very fast) - No performance concerns

Optimization for Large Templates:

// Skip preview for very large HTML\nconst renderPreview = (html: string, data: Record<string, unknown>) => {\n  if (html.length > 100000) { // 100KB\n    setPreviewError('Template too large for live preview. Use test send instead.');\n    return;\n  }\n\n  // Normal preview rendering...\n};\n

"},{"location":"v2/features/email-templates/editor/#accessibility","title":"Accessibility","text":""},{"location":"v2/features/email-templates/editor/#keyboard-shortcuts","title":"Keyboard Shortcuts","text":"

Implemented: - Ctrl+S (or Cmd+S on Mac) \u2014 Save template - Ctrl+P \u2014 Toggle preview pane - Esc \u2014 Close modal

Implementation:

useEffect(() => {\n  const handleKeyDown = (e: KeyboardEvent) => {\n    // Save\n    if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n      e.preventDefault();\n      setSaveModalVisible(true);\n    }\n\n    // Preview toggle\n    if ((e.ctrlKey || e.metaKey) && e.key === 'p') {\n      e.preventDefault();\n      setShowPreview(!showPreview);\n    }\n\n    // Close modal\n    if (e.key === 'Escape') {\n      setSaveModalVisible(false);\n      setTestModalVisible(false);\n    }\n  };\n\n  window.addEventListener('keydown', handleKeyDown);\n  return () => window.removeEventListener('keydown', handleKeyDown);\n}, [showPreview]);\n

"},{"location":"v2/features/email-templates/editor/#screen-reader-support","title":"Screen Reader Support","text":"

Form Labels:

<Form.Item label=\"Recipient Email\" required>\n  <Input\n    type=\"email\"\n    aria-label=\"Test email recipient address\"\n    aria-required=\"true\"\n    value={testRecipient}\n    onChange={(e) => setTestRecipient(e.target.value)}\n  />\n</Form.Item>\n

Button Descriptions:

<Button\n  icon={<SaveOutlined />}\n  onClick={() => setSaveModalVisible(true)}\n  aria-label=\"Save template and create new version\"\n>\n  Save\n</Button>\n\n<Button\n  size=\"small\"\n  onClick={() => handleInsertVariable(variable.key, 'html')}\n  aria-label={`Insert ${variable.label} variable into HTML editor`}\n>\n  Insert to HTML\n</Button>\n

"},{"location":"v2/features/email-templates/editor/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/email-templates/editor/#frontend-documentation","title":"Frontend Documentation","text":""},{"location":"v2/features/email-templates/editor/#backend-documentation","title":"Backend Documentation","text":""},{"location":"v2/features/email-templates/editor/#feature-documentation","title":"Feature Documentation","text":""},{"location":"v2/features/email-templates/editor/#related-features","title":"Related Features","text":""},{"location":"v2/features/email-templates/template-system/","title":"Email Template System","text":""},{"location":"v2/features/email-templates/template-system/#overview","title":"Overview","text":"

The Email Template System provides centralized management of all transactional and campaign emails sent by Changemaker Lite. It enables administrators to create, edit, and maintain email templates with variable interpolation, version control, and testing capabilities.

Key Features:

Use Cases:

"},{"location":"v2/features/email-templates/template-system/#architecture","title":"Architecture","text":"
flowchart TB\n    subgraph \"Email Service Layer\"\n        Service[EmailService<br/>email.service.ts]\n        Service --> Load[Load Template by Key]\n        Service --> Validate[Validate Required Variables]\n        Service --> Interpolate[Handlebars Interpolation]\n    end\n\n    subgraph \"Database Models\"\n        Template[(EmailTemplate)]\n        Variables[(EmailTemplateVariable)]\n        Versions[(EmailTemplateVersion)]\n        TestLogs[(EmailTemplateTestLog)]\n\n        Template -->|1:N| Variables\n        Template -->|1:N| Versions\n        Template -->|1:N| TestLogs\n    end\n\n    subgraph \"Output Channels\"\n        HTML[HTML Email]\n        Text[Plain Text Email]\n        Preview[Preview Rendering]\n    end\n\n    Load --> Template\n    Template --> Variables\n    Validate --> Variables\n\n    Interpolate --> HTML\n    Interpolate --> Text\n    Interpolate --> Preview\n\n    HTML --> SMTP[Nodemailer SMTP]\n    Text --> SMTP\n    Preview --> Admin[Admin GUI]\n\n    SMTP --> Sent[Email Sent]\n    Sent --> TestLogs\n\n    Service -.->|Test Mode| MailHog[MailHog<br/>Dev Capture]\n\n    style Service fill:#4a90e2,color:#fff\n    style Template fill:#50c878,color:#fff\n    style SMTP fill:#ff6b6b,color:#fff

Component Responsibilities:

"},{"location":"v2/features/email-templates/template-system/#database-models","title":"Database Models","text":""},{"location":"v2/features/email-templates/template-system/#emailtemplate","title":"EmailTemplate","text":"

Core template storage with metadata and content.

Field Type Description id String (CUID) Primary key key String (unique) Programmatic identifier (e.g., \"shift-signup-confirmation\") name String Display name for admin GUI description String (optional) Template purpose and usage notes category Enum INFLUENCE, MAP, or SYSTEM subjectLine String Email subject (supports {{VARIABLES}}) htmlContent Text HTML email body with Handlebars syntax textContent Text Plain text fallback version isSystem Boolean If true, cannot be deleted (critical platform emails) isActive Boolean If false, template is disabled and won't send createdAt DateTime Creation timestamp updatedAt DateTime Last modification timestamp createdByUserId String (optional) User who created template updatedByUserId String (optional) User who last modified template

Relations: - variables \u2014 EmailTemplateVariable[] (1:N) - versions \u2014 EmailTemplateVersion[] (1:N) - testLogs \u2014 EmailTemplateTestLog[] (1:N)

Indexes: - Unique index on key for fast lookups - Index on category for filtered queries - Index on isActive for production template queries

"},{"location":"v2/features/email-templates/template-system/#emailtemplatevariable","title":"EmailTemplateVariable","text":"

Variable definitions for template interpolation.

Field Type Description id String (CUID) Primary key templateId String Foreign key to EmailTemplate key String Variable name (e.g., \"USER_NAME\") label String Display label for admin GUI description String (optional) Variable purpose and usage notes isRequired Boolean If true, must be provided in data object isConditional Boolean If true, used in {{#if}} blocks (truthy/falsy) sampleValue String (optional) Example value for testing and preview sortOrder Int Display order in editor variable panel createdAt DateTime Creation timestamp

Relations: - template \u2014 EmailTemplate (N:1)

Constraints: - Unique index on (templateId, key) to prevent duplicate variables

"},{"location":"v2/features/email-templates/template-system/#emailtemplateversion","title":"EmailTemplateVersion","text":"

Version history snapshots for audit trail and rollback.

Field Type Description id String (CUID) Primary key templateId String Foreign key to EmailTemplate versionNumber Int Auto-incremented version number (1, 2, 3...) subjectLine String Subject at time of version htmlContent Text HTML content snapshot textContent Text Plain text content snapshot changeNotes String (optional) Admin-provided change description createdByUserId String (optional) User who created this version createdAt DateTime Version creation timestamp

Relations: - template \u2014 EmailTemplate (N:1) - createdBy \u2014 User (N:1)

Constraints: - Unique index on (templateId, versionNumber) for version lookup - Auto-increment logic in service layer (finds max + 1)

"},{"location":"v2/features/email-templates/template-system/#emailtemplatetestlog","title":"EmailTemplateTestLog","text":"

Test send audit trail for debugging and compliance.

Field Type Description id String (CUID) Primary key templateId String Foreign key to EmailTemplate recipientEmail String Email address test was sent to testData JSON Sample variable data used for interpolation success Boolean Whether send succeeded errorMessage String (optional) Error details if send failed messageId String (optional) SMTP message ID if send succeeded sentByUserId String (optional) User who triggered test send createdAt DateTime Test send timestamp

Relations: - template \u2014 EmailTemplate (N:1) - sentBy \u2014 User (N:1)

Indexes: - Index on templateId for template-specific test history - Index on createdAt for chronological queries

"},{"location":"v2/features/email-templates/template-system/#template-categories","title":"Template Categories","text":""},{"location":"v2/features/email-templates/template-system/#influence-category","title":"INFLUENCE Category","text":"

Purpose: Advocacy campaign emails sent to representatives or response notifications to participants.

System Templates:

Key Name Description campaign-email Campaign Email to Representative Main advocacy email template sent on behalf of participants response-verification Response Verification Email Email asking participants to verify their response submission response-approved Response Approval Notification Email notifying participant their response is published on wall response-rejected Response Rejection Notification Email notifying participant their response was rejected (with reason)

Common Variables: - USER_NAME \u2014 Participant's full name - USER_EMAIL \u2014 Participant's email address - CAMPAIGN_TITLE \u2014 Campaign name - CAMPAIGN_SLUG \u2014 URL-safe campaign identifier - REPRESENTATIVE_NAME \u2014 Representative's full name - REPRESENTATIVE_EMAIL \u2014 Representative's email address - REPRESENTATIVE_TITLE \u2014 Representative's title (e.g., \"MP for...\") - CUSTOM_MESSAGE \u2014 Participant's custom message to representative - RESPONSE_TEXT \u2014 Participant's response wall submission - VERIFICATION_LINK \u2014 Unique verification URL - ADMIN_NOTES \u2014 Moderator's rejection reason

"},{"location":"v2/features/email-templates/template-system/#map-category","title":"MAP Category","text":"

Purpose: Location-based emails for volunteer shifts, canvassing sessions, and shift management.

System Templates:

Key Name Description shift-signup-confirmation Shift Signup Confirmation Email confirming volunteer's shift registration shift-reminder Shift Reminder Email sent 24 hours before shift starts shift-cancellation Shift Cancellation Notice Email notifying volunteer of shift cancellation canvass-session-summary Canvass Session Summary End-of-session report with visit statistics

Common Variables: - USER_NAME \u2014 Volunteer's full name - USER_EMAIL \u2014 Volunteer's email address - USER_PHONE \u2014 Volunteer's phone number (optional) - SHIFT_TITLE \u2014 Shift name - SHIFT_DATE \u2014 Shift date (formatted) - SHIFT_TIME \u2014 Shift time range (e.g., \"10:00 AM - 2:00 PM\") - SHIFT_LOCATION \u2014 Shift meeting location - CUT_NAME \u2014 Canvass area name - VISIT_COUNT \u2014 Number of doors knocked - CONTACT_COUNT \u2014 Number of successful contacts - SUPPORT_COUNT \u2014 Number of supporters identified - CANCELLATION_REASON \u2014 Why shift was cancelled

"},{"location":"v2/features/email-templates/template-system/#system-category","title":"SYSTEM Category","text":"

Purpose: Core platform emails for user management, authentication, and system notifications.

System Templates:

Key Name Description user-welcome Welcome Email Email sent to new user registrations password-reset Password Reset Email Email with password reset link email-verification Email Verification Email address verification for new accounts account-locked Account Locked Notice Security notification for locked accounts

Common Variables: - USER_NAME \u2014 User's full name - USER_EMAIL \u2014 User's email address - VERIFICATION_LINK \u2014 Unique verification URL (expires in 24h) - RESET_LINK \u2014 Unique password reset URL (expires in 1h) - SUPPORT_EMAIL \u2014 Platform support email address - SITE_NAME \u2014 Platform name (from SiteSettings) - SITE_URL \u2014 Platform base URL - LOGIN_URL \u2014 Direct link to login page - LOCKOUT_REASON \u2014 Why account was locked

"},{"location":"v2/features/email-templates/template-system/#variable-interpolation","title":"Variable Interpolation","text":"

The template system uses Handlebars for powerful variable interpolation with support for basic variables, conditional blocks, loops, and helpers.

"},{"location":"v2/features/email-templates/template-system/#basic-variables","title":"Basic Variables","text":"

Syntax: {{VARIABLE_NAME}}

Example Template:

<p>Dear {{USER_NAME}},</p>\n\n<p>Thank you for signing up for <strong>{{SHIFT_TITLE}}</strong> on {{SHIFT_DATE}}.</p>\n\n<p>We'll see you at {{SHIFT_LOCATION}} at {{SHIFT_TIME}}.</p>\n\n<p>If you have any questions, email us at {{SUPPORT_EMAIL}}.</p>\n

Sample Data:

{\n  \"USER_NAME\": \"Jane Smith\",\n  \"SHIFT_TITLE\": \"Door Knocking - Downtown\",\n  \"SHIFT_DATE\": \"Saturday, March 15, 2026\",\n  \"SHIFT_LOCATION\": \"Campaign Office (123 Main St)\",\n  \"SHIFT_TIME\": \"10:00 AM - 2:00 PM\",\n  \"SUPPORT_EMAIL\": \"volunteer@example.org\"\n}\n

Rendered Output:

<p>Dear Jane Smith,</p>\n\n<p>Thank you for signing up for <strong>Door Knocking - Downtown</strong> on Saturday, March 15, 2026.</p>\n\n<p>We'll see you at Campaign Office (123 Main St) at 10:00 AM - 2:00 PM.</p>\n\n<p>If you have any questions, email us at volunteer@example.org.</p>\n

"},{"location":"v2/features/email-templates/template-system/#conditional-blocks","title":"Conditional Blocks","text":"

Syntax: {{#if CONDITION}} ... {{else}} ... {{/if}}

Example Template:

<p>Dear {{USER_NAME}},</p>\n\n<p>Your shift confirmation for {{SHIFT_TITLE}} is below.</p>\n\n{{#if HAS_PHONE}}\n<p><strong>We'll call you at {{USER_PHONE}} if there are any changes.</strong></p>\n{{else}}\n<p>We recommend adding a phone number to your profile for shift updates.</p>\n{{/if}}\n\n{{#if IS_CUT_ASSIGNED}}\n<p>You've been assigned to canvass <strong>{{CUT_NAME}}</strong>.</p>\n{{/if}}\n

Sample Data:

{\n  \"USER_NAME\": \"John Doe\",\n  \"SHIFT_TITLE\": \"Canvassing - North District\",\n  \"HAS_PHONE\": true,\n  \"USER_PHONE\": \"(555) 123-4567\",\n  \"IS_CUT_ASSIGNED\": true,\n  \"CUT_NAME\": \"North District - Zone A\"\n}\n

Rendered Output:

<p>Dear John Doe,</p>\n\n<p>Your shift confirmation for Canvassing - North District is below.</p>\n\n<p><strong>We'll call you at (555) 123-4567 if there are any changes.</strong></p>\n\n<p>You've been assigned to canvass <strong>North District - Zone A</strong>.</p>\n

Truthy/Falsy Values: - true, non-empty strings, non-zero numbers \u2192 truthy - false, null, undefined, 0, \"\" \u2192 falsy

"},{"location":"v2/features/email-templates/template-system/#loops-each-blocks","title":"Loops (Each Blocks)","text":"

Syntax: {{#each ARRAY}} ... {{/each}}

Example Template:

<p>Dear {{USER_NAME}},</p>\n\n<p>Your email will be sent to the following representatives:</p>\n\n<ul>\n{{#each REPRESENTATIVES}}\n  <li>\n    <strong>{{name}}</strong> ({{title}})<br>\n    Email: {{email}}\n  </li>\n{{/each}}\n</ul>\n\n<p>Your custom message:</p>\n<blockquote>{{CUSTOM_MESSAGE}}</blockquote>\n

Sample Data:

{\n  \"USER_NAME\": \"Alice Johnson\",\n  \"REPRESENTATIVES\": [\n    {\n      \"name\": \"Jane Doe\",\n      \"title\": \"MP for Downtown\",\n      \"email\": \"jane.doe@parliament.ca\"\n    },\n    {\n      \"name\": \"John Smith\",\n      \"title\": \"City Councillor Ward 3\",\n      \"email\": \"john.smith@city.ca\"\n    }\n  ],\n  \"CUSTOM_MESSAGE\": \"Please support Bill C-123 to address climate change.\"\n}\n

Rendered Output:

<p>Dear Alice Johnson,</p>\n\n<p>Your email will be sent to the following representatives:</p>\n\n<ul>\n  <li>\n    <strong>Jane Doe</strong> (MP for Downtown)<br>\n    Email: jane.doe@parliament.ca\n  </li>\n  <li>\n    <strong>John Smith</strong> (City Councillor Ward 3)<br>\n    Email: john.smith@city.ca\n  </li>\n</ul>\n\n<p>Your custom message:</p>\n<blockquote>Please support Bill C-123 to address climate change.</blockquote>\n

Loop Variables: - {{@index}} \u2014 0-based index - {{@first}} \u2014 true if first item - {{@last}} \u2014 true if last item

"},{"location":"v2/features/email-templates/template-system/#raw-html-unescaped","title":"Raw HTML (Unescaped)","text":"

Syntax: {{{VARIABLE_NAME}}} (triple braces)

By default, Handlebars escapes HTML to prevent XSS attacks. Use triple braces for trusted HTML content.

Example Template:

<p>Dear {{USER_NAME}},</p>\n\n<div class=\"message-content\">\n  {{{FORMATTED_MESSAGE}}}\n</div>\n

Sample Data:

{\n  \"USER_NAME\": \"Bob Wilson\",\n  \"FORMATTED_MESSAGE\": \"<p>This is <strong>bold</strong> and <em>italic</em> text.</p><ul><li>Item 1</li><li>Item 2</li></ul>\"\n}\n

Rendered Output:

<p>Dear Bob Wilson,</p>\n\n<div class=\"message-content\">\n  <p>This is <strong>bold</strong> and <em>italic</em> text.</p><ul><li>Item 1</li><li>Item 2</li></ul>\n</div>\n

Security Warning: Only use {{{...}}} for content generated by the application, never for user-submitted content without sanitization.

"},{"location":"v2/features/email-templates/template-system/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/email-templates/template-system/#viewing-templates","title":"Viewing Templates","text":"
  1. Navigate to Email Templates Page
  2. Admin sidebar \u2192 Email Templates
  3. Shows table with all templates grouped by category

  4. Filter and Search

  5. Filter by category (INFLUENCE, MAP, SYSTEM)
  6. Search by template name or key
  7. Toggle \"Show Inactive\" to view disabled templates

  8. Template Details

  9. Click template row to view details modal
  10. See subject line, category, active status, system flag
  11. View variable list with required/optional labels
  12. Access version history tab
  13. Access test send tab
"},{"location":"v2/features/email-templates/template-system/#creating-template","title":"Creating Template","text":"
  1. Click \"New Template\" Button
  2. Opens template creation modal

  3. Enter Template Metadata

  4. Key \u2014 Programmatic identifier (lowercase-with-dashes)
  5. Name \u2014 Display name for admin GUI
  6. Description \u2014 Template purpose and usage notes
  7. Category \u2014 Select INFLUENCE, MAP, or SYSTEM
  8. System Flag \u2014 Check if template is critical (prevents deletion)

  9. Define Variables

  10. Click \"Add Variable\" in variables section
  11. Enter variable key (UPPERCASE_WITH_UNDERSCORES)
  12. Enter label and description
  13. Toggle required/conditional flags
  14. Provide sample value for testing
  15. Set sort order (drag to reorder)

  16. Write Template Content

  17. Subject Line \u2014 Enter subject with optional {{VARIABLES}}
  18. HTML Content \u2014 Write HTML body with {{VARIABLES}}
  19. Text Content \u2014 Write plain text fallback

  20. Save Template

  21. Click \"Save\" to create template
  22. Creates version 1 automatically
  23. Template is active by default
"},{"location":"v2/features/email-templates/template-system/#editing-template","title":"Editing Template","text":"
  1. Open Template
  2. Email Templates page \u2192 click template
  3. Opens detail modal

  4. Click \"Edit\" Button

  5. Opens EmailTemplateEditorPage in new tab
  6. Shows split-pane editor (HTML + Text)

  7. Modify Content

  8. Edit subject line, HTML, or text content
  9. Use variable insertion buttons to add {{VARIABLES}}
  10. Preview rendered output with sample data

  11. Add Change Notes

  12. Enter description of changes in \"Change Notes\" field
  13. Used for version history audit trail

  14. Save Changes

  15. Click \"Save\" button
  16. Creates new version automatically
  17. Redirects to Email Templates page
"},{"location":"v2/features/email-templates/template-system/#testing-template","title":"Testing Template","text":"
  1. Open Template Detail Modal
  2. Click template from list

  3. Navigate to \"Test Send\" Tab

  4. Enter Test Parameters

  5. Recipient Email \u2014 Your email address for test
  6. Sample Data \u2014 JSON object with variable values
  7. Pre-filled with variable sample values

  8. Click \"Send Test Email\"

  9. Template is rendered with sample data
  10. Email sent via SMTP (or MailHog in test mode)
  11. Success/failure notification displayed

  12. Check Test Log

  13. View test send history in \"Test Logs\" tab
  14. See timestamp, recipient, success status, error messages
  15. Review sample data used for each test
"},{"location":"v2/features/email-templates/template-system/#activatingdeactivating-template","title":"Activating/Deactivating Template","text":"
  1. Open Template Detail Modal

  2. Toggle \"Active\" Switch

  3. When inactive, template won't send emails
  4. Useful for disabling seasonal templates or broken templates

  5. Confirm Action

  6. System templates require additional confirmation
  7. Deactivating system template may break critical platform functions
"},{"location":"v2/features/email-templates/template-system/#developer-workflow-adding-new-template","title":"Developer Workflow (Adding New Template)","text":""},{"location":"v2/features/email-templates/template-system/#step-1-define-template-key","title":"Step 1: Define Template Key","text":"

Choose a descriptive, unique key using lowercase with dashes:

Good Keys: - shift-signup-confirmation - canvass-session-summary - response-verification

Bad Keys: - template1 (not descriptive) - ShiftSignup (wrong case) - shift_signup (use dashes, not underscores)

"},{"location":"v2/features/email-templates/template-system/#step-2-create-template-via-seed-script","title":"Step 2: Create Template via Seed Script","text":"

Add to api/prisma/seed.ts:

await prisma.emailTemplate.upsert({\n  where: { key: 'shift-signup-confirmation' },\n  update: {},\n  create: {\n    key: 'shift-signup-confirmation',\n    name: 'Shift Signup Confirmation',\n    description: 'Email sent to volunteers when they sign up for a shift',\n    category: 'MAP',\n    isSystem: true,\n    isActive: true,\n    subjectLine: 'Confirmed: {{SHIFT_TITLE}} on {{SHIFT_DATE}}',\n    htmlContent: `\n      <p>Dear {{USER_NAME}},</p>\n\n      <p>Thank you for signing up for <strong>{{SHIFT_TITLE}}</strong>!</p>\n\n      <p><strong>Details:</strong></p>\n      <ul>\n        <li><strong>Date:</strong> {{SHIFT_DATE}}</li>\n        <li><strong>Time:</strong> {{SHIFT_TIME}}</li>\n        <li><strong>Location:</strong> {{SHIFT_LOCATION}}</li>\n      </ul>\n\n      {{#if HAS_PHONE}}\n      <p>We'll call you at {{USER_PHONE}} if there are any changes.</p>\n      {{/if}}\n\n      <p>See you there!</p>\n    `,\n    textContent: `\nDear {{USER_NAME}},\n\nThank you for signing up for {{SHIFT_TITLE}}!\n\nDetails:\n- Date: {{SHIFT_DATE}}\n- Time: {{SHIFT_TIME}}\n- Location: {{SHIFT_LOCATION}}\n\n{{#if HAS_PHONE}}\nWe'll call you at {{USER_PHONE}} if there are any changes.\n{{/if}}\n\nSee you there!\n    `,\n  },\n});\n

Run Seed:

docker compose exec api npx prisma db seed\n

"},{"location":"v2/features/email-templates/template-system/#step-3-define-variables","title":"Step 3: Define Variables","text":"

Add variables in same seed script:

const template = await prisma.emailTemplate.findUnique({\n  where: { key: 'shift-signup-confirmation' },\n});\n\nconst variables = [\n  {\n    key: 'USER_NAME',\n    label: 'User Name',\n    description: 'Full name of the volunteer',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: 'John Doe',\n    sortOrder: 1,\n  },\n  {\n    key: 'SHIFT_TITLE',\n    label: 'Shift Title',\n    description: 'Name of the shift',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: 'Door Knocking - Downtown',\n    sortOrder: 2,\n  },\n  {\n    key: 'SHIFT_DATE',\n    label: 'Shift Date',\n    description: 'Formatted shift date',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: 'Saturday, March 15, 2026',\n    sortOrder: 3,\n  },\n  {\n    key: 'SHIFT_TIME',\n    label: 'Shift Time',\n    description: 'Shift time range',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: '10:00 AM - 2:00 PM',\n    sortOrder: 4,\n  },\n  {\n    key: 'SHIFT_LOCATION',\n    label: 'Shift Location',\n    description: 'Meeting location for shift',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: 'Campaign Office (123 Main St)',\n    sortOrder: 5,\n  },\n  {\n    key: 'HAS_PHONE',\n    label: 'Has Phone',\n    description: 'Whether user provided phone number',\n    isRequired: false,\n    isConditional: true,\n    sampleValue: 'true',\n    sortOrder: 6,\n  },\n  {\n    key: 'USER_PHONE',\n    label: 'User Phone',\n    description: 'User phone number (optional)',\n    isRequired: false,\n    isConditional: false,\n    sampleValue: '(555) 123-4567',\n    sortOrder: 7,\n  },\n];\n\nfor (const variable of variables) {\n  await prisma.emailTemplateVariable.upsert({\n    where: {\n      templateId_key: {\n        templateId: template!.id,\n        key: variable.key,\n      },\n    },\n    update: {},\n    create: {\n      templateId: template!.id,\n      ...variable,\n    },\n  });\n}\n
"},{"location":"v2/features/email-templates/template-system/#step-4-use-in-code","title":"Step 4: Use in Code","text":"

Send email from template:

import { emailService } from '@/services/email.service';\n\nawait emailService.sendFromTemplate('shift-signup-confirmation', {\n  recipientEmail: volunteer.email,\n  data: {\n    USER_NAME: volunteer.name,\n    SHIFT_TITLE: shift.title,\n    SHIFT_DATE: dayjs(shift.startTime).format('dddd, MMMM D, YYYY'),\n    SHIFT_TIME: `${dayjs(shift.startTime).format('h:mm A')} - ${dayjs(shift.endTime).format('h:mm A')}`,\n    SHIFT_LOCATION: shift.location,\n    HAS_PHONE: !!volunteer.phone,\n    USER_PHONE: volunteer.phone || '',\n  },\n});\n
"},{"location":"v2/features/email-templates/template-system/#step-5-document-template","title":"Step 5: Document Template","text":"

Add to API documentation:

Create entry in mkdocs/docs/v2/api/email-templates.md:

## shift-signup-confirmation\n\n**Category:** MAP\n**System:** Yes\n\nSent when volunteer signs up for a shift.\n\n**Required Variables:**\n- USER_NAME, SHIFT_TITLE, SHIFT_DATE, SHIFT_TIME, SHIFT_LOCATION\n\n**Optional Variables:**\n- HAS_PHONE (conditional), USER_PHONE\n\n**Usage:**\n\\```typescript\nawait emailService.sendFromTemplate('shift-signup-confirmation', { ... });\n\\```\n
"},{"location":"v2/features/email-templates/template-system/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/email-templates/template-system/#send-email-from-template","title":"Send Email from Template","text":"

Basic Usage:

import { emailService } from '@/services/email.service';\n\nawait emailService.sendFromTemplate('user-welcome', {\n  recipientEmail: 'newuser@example.com',\n  data: {\n    USER_NAME: 'Alice Smith',\n    USER_EMAIL: 'newuser@example.com',\n    VERIFICATION_LINK: 'https://cmlite.org/verify/abc123',\n    SITE_NAME: 'Changemaker Lite',\n    SITE_URL: 'https://cmlite.org',\n  },\n});\n

With Conditional Variables:

await emailService.sendFromTemplate('response-verification', {\n  recipientEmail: participant.email,\n  data: {\n    USER_NAME: participant.name,\n    CAMPAIGN_TITLE: campaign.title,\n    RESPONSE_TEXT: response.content,\n    VERIFICATION_LINK: `https://cmlite.org/responses/verify/${response.verificationToken}`,\n    HAS_CUSTOM_MESSAGE: !!response.customMessage,\n    CUSTOM_MESSAGE: response.customMessage || '',\n  },\n});\n

With Loops (Array Variables):

await emailService.sendFromTemplate('campaign-email', {\n  recipientEmail: 'representative@parliament.ca',\n  data: {\n    USER_NAME: participant.name,\n    USER_EMAIL: participant.email,\n    CAMPAIGN_TITLE: campaign.title,\n    CUSTOM_MESSAGE: emailData.customMessage,\n    REPRESENTATIVES: emailData.representatives.map(rep => ({\n      name: rep.name,\n      title: rep.title,\n      email: rep.email,\n    })),\n  },\n});\n
"},{"location":"v2/features/email-templates/template-system/#template-service-implementation","title":"Template Service Implementation","text":"

Core sendFromTemplate Method:

// api/src/services/email.service.ts\n\nimport Handlebars from 'handlebars';\nimport { prisma } from '@/config/database';\nimport { EmailTemplateNotFoundError, MissingRequiredVariableError } from '@/utils/errors';\n\nclass EmailService {\n  async sendFromTemplate(\n    templateKey: string,\n    options: {\n      recipientEmail: string;\n      data: Record<string, unknown>;\n      attachments?: Array<{ filename: string; path: string }>;\n    }\n  ) {\n    // 1. Load template with variables\n    const template = await prisma.emailTemplate.findUnique({\n      where: { key: templateKey, isActive: true },\n      include: { variables: true },\n    });\n\n    if (!template) {\n      throw new EmailTemplateNotFoundError(`Template not found or inactive: ${templateKey}`);\n    }\n\n    // 2. Validate required variables\n    const requiredVars = template.variables.filter(v => v.isRequired);\n    const missingVars: string[] = [];\n\n    for (const variable of requiredVars) {\n      if (options.data[variable.key] === undefined || options.data[variable.key] === null) {\n        missingVars.push(variable.key);\n      }\n    }\n\n    if (missingVars.length > 0) {\n      throw new MissingRequiredVariableError(\n        `Missing required variables for template ${templateKey}: ${missingVars.join(', ')}`\n      );\n    }\n\n    // 3. Compile Handlebars templates\n    const compiledSubject = Handlebars.compile(template.subjectLine);\n    const compiledHtml = Handlebars.compile(template.htmlContent);\n    const compiledText = Handlebars.compile(template.textContent);\n\n    // 4. Interpolate variables\n    const subject = compiledSubject(options.data);\n    const html = compiledHtml(options.data);\n    const text = compiledText(options.data);\n\n    // 5. Send via Nodemailer\n    const result = await this.send({\n      to: options.recipientEmail,\n      subject,\n      html,\n      text,\n      attachments: options.attachments,\n    });\n\n    return result;\n  }\n\n  private async send(options: {\n    to: string;\n    subject: string;\n    html: string;\n    text: string;\n    attachments?: Array<{ filename: string; path: string }>;\n  }) {\n    // Nodemailer implementation\n    // See api/src/services/email.service.ts for full implementation\n  }\n}\n\nexport const emailService = new EmailService();\n
"},{"location":"v2/features/email-templates/template-system/#handlebars-helper-registration","title":"Handlebars Helper Registration","text":"

Register custom helpers for common formatting:

// api/src/services/email.service.ts\n\nimport Handlebars from 'handlebars';\nimport dayjs from 'dayjs';\n\n// Date formatting helper\nHandlebars.registerHelper('formatDate', (date: string | Date, format: string) => {\n  return dayjs(date).format(format);\n});\n\n// Currency formatting helper\nHandlebars.registerHelper('currency', (amount: number) => {\n  return new Intl.NumberFormat('en-CA', {\n    style: 'currency',\n    currency: 'CAD',\n  }).format(amount);\n});\n\n// Pluralize helper\nHandlebars.registerHelper('pluralize', (count: number, singular: string, plural: string) => {\n  return count === 1 ? singular : plural;\n});\n

Usage in Templates:

<p>Your shift is scheduled for {{formatDate SHIFT_DATE \"MMMM D, YYYY\"}}.</p>\n\n<p>You've knocked on {{DOOR_COUNT}} {{pluralize DOOR_COUNT \"door\" \"doors\"}}.</p>\n\n<p>Campaign budget: {{currency CAMPAIGN_BUDGET}}</p>\n
"},{"location":"v2/features/email-templates/template-system/#error-handling","title":"Error Handling","text":"

Custom Error Classes:

// api/src/utils/errors.ts\n\nexport class EmailTemplateNotFoundError extends Error {\n  constructor(message: string) {\n    super(message);\n    this.name = 'EmailTemplateNotFoundError';\n  }\n}\n\nexport class MissingRequiredVariableError extends Error {\n  constructor(message: string) {\n    super(message);\n    this.name = 'MissingRequiredVariableError';\n  }\n}\n\nexport class TemplateCompilationError extends Error {\n  constructor(message: string) {\n    super(message);\n    this.name = 'TemplateCompilationError';\n  }\n}\n

Service Error Handling:

try {\n  await emailService.sendFromTemplate('shift-reminder', {\n    recipientEmail: volunteer.email,\n    data: { ... },\n  });\n} catch (error) {\n  if (error instanceof EmailTemplateNotFoundError) {\n    logger.error('Template not found', { templateKey: 'shift-reminder' });\n    // Fallback to default email or skip send\n  } else if (error instanceof MissingRequiredVariableError) {\n    logger.error('Missing required variables', { error: error.message });\n    // Log to Sentry, notify admin\n  } else {\n    logger.error('Email send failed', { error });\n    throw error;\n  }\n}\n
"},{"location":"v2/features/email-templates/template-system/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/email-templates/template-system/#problem-template-not-found","title":"Problem: Template not found","text":"

Symptoms: - EmailTemplateNotFoundError: Template not found or inactive: shift-reminder - Email not sent, exception thrown

Causes: 1. Template key typo (case-sensitive) 2. Template is inactive (isActive = false) 3. Template doesn't exist in database

Solutions:

Check template exists:

SELECT * FROM email_templates WHERE key = 'shift-reminder';\n

Check active status:

SELECT key, is_active FROM email_templates WHERE key = 'shift-reminder';\n

Activate template:

UPDATE email_templates SET is_active = true WHERE key = 'shift-reminder';\n

Create template via admin GUI or seed script (see Developer Workflow above)

"},{"location":"v2/features/email-templates/template-system/#problem-variable-not-replaced-shows-var-in-email","title":"Problem: Variable not replaced (shows {{VAR}} in email)","text":"

Symptoms: - Rendered email shows {{USER_NAME}} instead of \"John Doe\" - Variables appear as raw text in subject or body

Causes: 1. Variable key typo in data object (case-sensitive) 2. Variable not provided in data object 3. Handlebars compilation failed silently 4. Using wrong interpolation syntax

Solutions:

Check variable key matches exactly:

// Template uses {{USER_NAME}}\n// Data must have USER_NAME (not userName or user_name)\ndata: {\n  USER_NAME: 'John Doe',  // \u2713 Correct\n  userName: 'John Doe',   // \u2717 Wrong case\n  user_name: 'John Doe',  // \u2717 Wrong format\n}\n

Console log data object:

console.log('Template data:', JSON.stringify(options.data, null, 2));\n

Test Handlebars compilation:

const Handlebars = require('handlebars');\nconst template = Handlebars.compile('Hello {{USER_NAME}}!');\nconsole.log(template({ USER_NAME: 'Test' })); // Should output: \"Hello Test!\"\n

Verify template content:

SELECT subject_line, html_content FROM email_templates WHERE key = 'shift-reminder';\n

"},{"location":"v2/features/email-templates/template-system/#problem-missing-required-variable-error","title":"Problem: Missing required variable error","text":"

Symptoms: - MissingRequiredVariableError: Missing required variables for template shift-reminder: SHIFT_DATE, SHIFT_TIME - Email not sent, exception thrown

Causes: 1. Required variable not provided in data object 2. Variable value is null or undefined

Solutions:

Check EmailTemplateVariable.isRequired:

SELECT key, label, is_required\nFROM email_template_variables\nWHERE template_id = (SELECT id FROM email_templates WHERE key = 'shift-reminder');\n

Provide all required variables:

await emailService.sendFromTemplate('shift-reminder', {\n  recipientEmail: volunteer.email,\n  data: {\n    USER_NAME: volunteer.name,\n    SHIFT_DATE: dayjs(shift.startTime).format('MMMM D, YYYY'),  // \u2713 Required\n    SHIFT_TIME: dayjs(shift.startTime).format('h:mm A'),         // \u2713 Required\n  },\n});\n

Temporary fix (set isRequired = false):

UPDATE email_template_variables\nSET is_required = false\nWHERE template_id = (SELECT id FROM email_templates WHERE key = 'shift-reminder')\n  AND key = 'SHIFT_TIME';\n

Long-term fix: Update code to always provide required variables

"},{"location":"v2/features/email-templates/template-system/#problem-email-sent-to-wrong-recipient","title":"Problem: Email sent to wrong recipient","text":"

Symptoms: - Test email sent to production recipient - User receives email meant for another user

Causes: 1. Wrong recipientEmail parameter 2. Email test mode disabled (EMAIL_TEST_MODE=false) 3. Variable interpolation pulled wrong user data

Solutions:

Enable test mode in development:

# .env\nEMAIL_TEST_MODE=true\n

Check recipient email:

console.log('Sending email to:', options.recipientEmail);\n

Use MailHog in dev: - All emails captured at http://localhost:8025 - Never sent to real recipients

Verify user data query:

const volunteer = await prisma.user.findUnique({ where: { id: volunteerId } });\nconsole.log('Volunteer email:', volunteer.email);\n

"},{"location":"v2/features/email-templates/template-system/#problem-html-rendering-broken-in-email-client","title":"Problem: HTML rendering broken in email client","text":"

Symptoms: - Email looks correct in preview but broken in Gmail/Outlook - Images not loading - Styles not applied

Causes: 1. Email client doesn't support modern CSS 2. External images blocked by email client 3. Invalid HTML structure

Solutions:

Use inline styles (not CSS classes):

<!-- \u2717 Won't work in many email clients -->\n<p class=\"highlight\">Important message</p>\n\n<!-- \u2713 Use inline styles -->\n<p style=\"background-color: #ffeb3b; padding: 10px; font-weight: bold;\">Important message</p>\n

Use tables for layout (not flexbox/grid):

<!-- \u2713 Email-safe layout -->\n<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\">\n  <tr>\n    <td style=\"padding: 20px;\">\n      <p>Content here</p>\n    </td>\n  </tr>\n</table>\n

Embed images as data URIs or use absolute URLs:

<!-- \u2713 Absolute URL -->\n<img src=\"https://cmlite.org/logo.png\" alt=\"Logo\">\n\n<!-- \u2713 Data URI (small images only) -->\n<img src=\"data:image/png;base64,iVBORw0KG...\" alt=\"Icon\">\n

Test in multiple email clients: - Use Litmus or Email on Acid - Test in Gmail, Outlook, Apple Mail, Yahoo Mail

"},{"location":"v2/features/email-templates/template-system/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/email-templates/template-system/#template-loading","title":"Template Loading","text":"

Current Implementation: - Templates fetched from database on every send - Includes variable definitions in same query - No caching layer

Performance Impact: - Single database query per email send (~10ms) - Acceptable for low-volume sends (< 100/min) - May bottleneck for high-volume campaigns (> 1000/min)

Optimization Options:

1. In-Memory Caching:

// api/src/services/email.service.ts\n\nprivate templateCache = new Map<string, EmailTemplate & { variables: EmailTemplateVariable[] }>();\nprivate cacheExpiry = new Map<string, number>();\nprivate readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes\n\nasync loadTemplate(key: string) {\n  const now = Date.now();\n\n  // Check cache\n  if (this.templateCache.has(key) && this.cacheExpiry.get(key)! > now) {\n    return this.templateCache.get(key)!;\n  }\n\n  // Load from database\n  const template = await prisma.emailTemplate.findUnique({\n    where: { key, isActive: true },\n    include: { variables: true },\n  });\n\n  if (!template) throw new EmailTemplateNotFoundError(`Template not found: ${key}`);\n\n  // Cache template\n  this.templateCache.set(key, template);\n  this.cacheExpiry.set(key, now + this.CACHE_TTL);\n\n  return template;\n}\n

2. Redis Caching:

import { redis } from '@/config/redis';\n\nasync loadTemplate(key: string) {\n  // Try Redis cache\n  const cached = await redis.get(`email-template:${key}`);\n  if (cached) {\n    return JSON.parse(cached);\n  }\n\n  // Load from database\n  const template = await prisma.emailTemplate.findUnique({ ... });\n\n  // Cache in Redis (5 min TTL)\n  await redis.setex(`email-template:${key}`, 300, JSON.stringify(template));\n\n  return template;\n}\n

3. Cache Invalidation:

// When template is updated\nawait redis.del(`email-template:${template.key}`);\nthis.templateCache.delete(template.key);\n

"},{"location":"v2/features/email-templates/template-system/#handlebars-compilation","title":"Handlebars Compilation","text":"

Performance: - Handlebars compilation is fast (~1ms per template) - No significant bottleneck for typical templates

Large Templates: - Templates > 100KB may take 5-10ms to compile - Solution: Pre-compile templates and cache compiled functions

Pre-Compilation:

private compiledCache = new Map<string, {\n  subject: HandlebarsTemplateDelegate;\n  html: HandlebarsTemplateDelegate;\n  text: HandlebarsTemplateDelegate;\n}>();\n\nasync sendFromTemplate(templateKey: string, options: { ... }) {\n  const template = await this.loadTemplate(templateKey);\n\n  // Check compiled cache\n  let compiled = this.compiledCache.get(templateKey);\n\n  if (!compiled) {\n    compiled = {\n      subject: Handlebars.compile(template.subjectLine),\n      html: Handlebars.compile(template.htmlContent),\n      text: Handlebars.compile(template.textContent),\n    };\n    this.compiledCache.set(templateKey, compiled);\n  }\n\n  // Interpolate\n  const subject = compiled.subject(options.data);\n  const html = compiled.html(options.data);\n  const text = compiled.text(options.data);\n\n  // Send...\n}\n

"},{"location":"v2/features/email-templates/template-system/#bulk-email-sending","title":"Bulk Email Sending","text":"

Problem: Sending 1000+ emails sequentially is slow (1-2 seconds per email)

Solution: Use BullMQ job queue for async batch processing

Queue Implementation:

// api/src/services/email-queue.service.ts\n\nimport { Queue, Worker } from 'bullmq';\nimport { redis } from '@/config/redis';\n\nconst emailQueue = new Queue('email-queue', {\n  connection: redis,\n});\n\n// Add email job\nexport async function queueEmail(templateKey: string, options: { ... }) {\n  await emailQueue.add('send-template', {\n    templateKey,\n    recipientEmail: options.recipientEmail,\n    data: options.data,\n  });\n}\n\n// Process email jobs\nconst emailWorker = new Worker('email-queue', async (job) => {\n  const { templateKey, recipientEmail, data } = job.data;\n  await emailService.sendFromTemplate(templateKey, { recipientEmail, data });\n}, {\n  connection: redis,\n  concurrency: 10, // Process 10 emails in parallel\n});\n

Usage:

// Queue 1000 emails\nfor (const volunteer of volunteers) {\n  await queueEmail('shift-reminder', {\n    recipientEmail: volunteer.email,\n    data: { ... },\n  });\n}\n

"},{"location":"v2/features/email-templates/template-system/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/features/email-templates/template-system/#xss-cross-site-scripting-in-email-clients","title":"XSS (Cross-Site Scripting) in Email Clients","text":"

Risk: Admin-authored templates may contain malicious JavaScript

Handlebars Auto-Escaping: - By default, {{VAR}} escapes HTML entities - & \u2192 &amp;, < \u2192 &lt;, > \u2192 &gt;

Raw HTML (Unescaped): - {{{VAR}}} (triple braces) renders raw HTML - Use ONLY for trusted, application-generated content - NEVER use for user-submitted content without sanitization

Example:

<!-- Safe: auto-escaped -->\n<p>User message: {{USER_MESSAGE}}</p>\n\n<!-- Unsafe: unescaped (only use for trusted content) -->\n<div>{{{FORMATTED_CONTENT}}}</div>\n

Sanitization:

import DOMPurify from 'isomorphic-dompurify';\n\nconst sanitizedMessage = DOMPurify.sanitize(userInput, {\n  ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li'],\n  ALLOWED_ATTR: [],\n});\n\nawait emailService.sendFromTemplate('response-notification', {\n  recipientEmail: admin.email,\n  data: {\n    USER_MESSAGE: sanitizedMessage, // Safe to use {{{...}}}\n  },\n});\n

"},{"location":"v2/features/email-templates/template-system/#email-address-validation","title":"Email Address Validation","text":"

Risk: Invalid email addresses cause SMTP errors or bounce emails

Validation Before Sending:

import validator from 'validator';\n\nif (!validator.isEmail(options.recipientEmail)) {\n  throw new Error('Invalid recipient email address');\n}\n

Bounce Handling: - Monitor bounce notifications from SMTP provider - Mark bounced emails in database - Disable sending to repeatedly bounced addresses

"},{"location":"v2/features/email-templates/template-system/#rate-limiting-template-test-sends","title":"Rate Limiting Template Test Sends","text":"

Risk: Admin spamming test sends

Rate Limit Implementation:

// api/src/modules/email-templates/email-templates.routes.ts\n\nimport rateLimit from 'express-rate-limit';\n\nconst testSendLimiter = rateLimit({\n  windowMs: 60 * 1000, // 1 minute\n  max: 10, // 10 requests per minute\n  message: 'Too many test sends. Please wait before trying again.',\n  standardHeaders: true,\n  legacyHeaders: false,\n});\n\nrouter.post('/:id/test', testSendLimiter, requireRole(SUPER_ADMIN), async (req, res) => {\n  // Test send implementation...\n});\n

"},{"location":"v2/features/email-templates/template-system/#template-injection-attacks","title":"Template Injection Attacks","text":"

Risk: Admin injects malicious Handlebars helpers or expressions

Handlebars Security: - Handlebars does NOT execute JavaScript (unlike eval) - Helpers are pre-registered by application (admin can't add custom helpers) - No access to Node.js globals or require()

Safe:

{{USER_NAME}}\n{{#if HAS_PHONE}}{{USER_PHONE}}{{/if}}\n{{#each ITEMS}}{{name}}{{/each}}\n

Already Prevented by Handlebars:

<!-- These do NOT execute, render as literal text -->\n{{require('fs').readFileSync('/etc/passwd')}}\n{{process.env.DATABASE_URL}}\n

Best Practice: Still review templates before activating

"},{"location":"v2/features/email-templates/template-system/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/email-templates/template-system/#frontend-documentation","title":"Frontend Documentation","text":""},{"location":"v2/features/email-templates/template-system/#backend-documentation","title":"Backend Documentation","text":""},{"location":"v2/features/email-templates/template-system/#database-documentation","title":"Database Documentation","text":""},{"location":"v2/features/email-templates/template-system/#feature-documentation","title":"Feature Documentation","text":""},{"location":"v2/features/email-templates/template-system/#configuration","title":"Configuration","text":""},{"location":"v2/features/email-templates/variables/","title":"Template Variables System","text":""},{"location":"v2/features/email-templates/variables/#overview","title":"Overview","text":"

The Template Variables System defines reusable placeholders for email templates, enabling dynamic content interpolation with validation, documentation, and sample values. Variables are defined per template and provide metadata for variable insertion UI, validation logic, and testing workflows.

Key Features:

Benefits:

"},{"location":"v2/features/email-templates/variables/#architecture","title":"Architecture","text":"
flowchart TB\n    subgraph \"Database Layer\"\n        Template[(EmailTemplate)]\n        Variables[(EmailTemplateVariable)]\n\n        Template -->|1:N| Variables\n    end\n\n    subgraph \"Variable Definition\"\n        VarKey[Variable Key<br/>USER_NAME]\n        VarMeta[Metadata<br/>label, description, isRequired]\n        VarSample[Sample Value<br/>'John Doe']\n        VarSort[Sort Order<br/>1, 2, 3...]\n\n        VarKey --> Variables\n        VarMeta --> Variables\n        VarSample --> Variables\n        VarSort --> Variables\n    end\n\n    subgraph \"Template Service\"\n        Load[Load Template + Variables]\n        Validate[Validate Required Variables]\n        Interpolate[Handlebars Interpolation]\n\n        Load --> Template\n        Load --> Variables\n        Validate --> Variables\n        Interpolate -->|{{VAR}}| Data[Data Object]\n    end\n\n    subgraph \"Editor UI\"\n        InsertPanel[Variable Insertion Panel]\n        PreviewForm[Sample Data Form]\n        TestSend[Test Send Form]\n\n        Variables --> InsertPanel\n        Variables --> PreviewForm\n        Variables --> TestSend\n    end\n\n    subgraph \"Runtime Validation\"\n        Send[Send Email]\n        Check{Required<br/>Variables<br/>Present?}\n        Error[Throw MissingVariableError]\n        Success[Send via SMTP]\n\n        Send --> Validate\n        Validate --> Check\n        Check -->|No| Error\n        Check -->|Yes| Interpolate\n        Interpolate --> Success\n    end\n\n    style Template fill:#50c878,color:#fff\n    style Variables fill:#4a90e2,color:#fff\n    style Validate fill:#ffb347,color:#333

Component Responsibilities:

"},{"location":"v2/features/email-templates/variables/#database-model","title":"Database Model","text":""},{"location":"v2/features/email-templates/variables/#emailtemplatevariable-schema","title":"EmailTemplateVariable Schema","text":"

Table: email_template_variables

Field Type Description id String (CUID) Primary key templateId String Foreign key to EmailTemplate key String Variable name (UPPERCASE_WITH_UNDERSCORES) label String Display label for UI (\"User's Full Name\") description String (optional) Variable purpose and usage notes isRequired Boolean If true, must be provided in data object isConditional Boolean If true, used in {{#if}} blocks (truthy/falsy) sampleValue String (optional) Example value for testing/preview sortOrder Int Display order in UI (1, 2, 3...) createdAt DateTime Creation timestamp

Relations: - template \u2014 EmailTemplate (N:1)

Constraints: - Unique index on (templateId, key) \u2014 prevents duplicate variables per template - Index on sortOrder for ordered queries

Prisma Schema:

model EmailTemplateVariable {\n  id            String   @id @default(cuid())\n  templateId    String\n  key           String\n  label         String\n  description   String?\n  isRequired    Boolean  @default(false)\n  isConditional Boolean  @default(false)\n  sampleValue   String?\n  sortOrder     Int      @default(0)\n  createdAt     DateTime @default(now())\n\n  template EmailTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)\n\n  @@unique([templateId, key])\n  @@index([sortOrder])\n  @@map(\"email_template_variables\")\n}\n

"},{"location":"v2/features/email-templates/variables/#variable-types","title":"Variable Types","text":""},{"location":"v2/features/email-templates/variables/#required-variables","title":"Required Variables","text":"

Purpose: Must be provided in data object for template to send.

Behavior: - Validation checks for presence before interpolation - Throws MissingRequiredVariableError if missing - Marked with red \"Required\" badge in editor UI

When to Use: - Variables that appear in ALL template renders - Variables without fallback values - Critical data (e.g., recipient name, event date)

Example:

await prisma.emailTemplateVariable.create({\n  data: {\n    templateId: template.id,\n    key: 'USER_NAME',\n    label: 'User Name',\n    description: 'Full name of the email recipient',\n    isRequired: true,  // \u2190 MUST be provided\n    isConditional: false,\n    sampleValue: 'John Doe',\n    sortOrder: 1,\n  },\n});\n

Template Usage:

<p>Dear {{USER_NAME}},</p>\n<!-- USER_NAME is required, error if missing -->\n

"},{"location":"v2/features/email-templates/variables/#optional-variables","title":"Optional Variables","text":"

Purpose: May be omitted from data object (defaults to empty string).

Behavior: - No validation error if missing - Handlebars renders as empty string if undefined - Useful for conditional content or nice-to-have data

When to Use: - Variables that may not always be available (e.g., phone number) - Variables with fallback text in template - Conditional blocks that check presence

Example:

await prisma.emailTemplateVariable.create({\n  data: {\n    templateId: template.id,\n    key: 'USER_PHONE',\n    label: 'User Phone',\n    description: 'User phone number (optional)',\n    isRequired: false,  // \u2190 Can be omitted\n    isConditional: false,\n    sampleValue: '(555) 123-4567',\n    sortOrder: 5,\n  },\n});\n

Template Usage:

{{#if USER_PHONE}}\n<p>We'll call you at {{USER_PHONE}}.</p>\n{{else}}\n<p>Add a phone number to receive SMS updates.</p>\n{{/if}}\n

"},{"location":"v2/features/email-templates/variables/#conditional-variables","title":"Conditional Variables","text":"

Purpose: Boolean or truthy/falsy values for {{#if}} blocks.

Behavior: - isConditional: true marks variable as boolean-like - Editor UI shows blue \"Conditional\" badge - Used in {{#if VAR}}...{{/if}} blocks - Can also be required or optional

When to Use: - Boolean flags (HAS_PHONE, IS_VERIFIED, IS_ADMIN) - Existence checks (HAS_CUSTOM_MESSAGE, HAS_LOCATION) - Feature flags (SHOW_DISCOUNT, SHOW_MAP_LINK)

Example:

await prisma.emailTemplateVariable.create({\n  data: {\n    templateId: template.id,\n    key: 'HAS_PHONE',\n    label: 'Has Phone Number',\n    description: 'Whether user provided a phone number',\n    isRequired: false,\n    isConditional: true,  // \u2190 Boolean/truthy variable\n    sampleValue: 'true',\n    sortOrder: 4,\n  },\n});\n

Template Usage:

{{#if HAS_PHONE}}\n<p>Contact: {{USER_PHONE}}</p>\n{{/if}}\n

Truthy Values: - true, 'true', 1, non-empty strings, non-empty arrays

Falsy Values: - false, 'false', 0, '', null, undefined, []

"},{"location":"v2/features/email-templates/variables/#array-variables-loops","title":"Array Variables (Loops)","text":"

Purpose: Collections for {{#each}} blocks.

Behavior: - Not explicitly marked (same as other variables) - Sample value should be JSON array string - Used in {{#each VAR}}...{{/each}} loops

When to Use: - Lists of representatives, shift assignments, visit outcomes - Dynamic content length (1-N items)

Example:

await prisma.emailTemplateVariable.create({\n  data: {\n    templateId: template.id,\n    key: 'REPRESENTATIVES',\n    label: 'Representative List',\n    description: 'Array of representative objects',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: JSON.stringify([\n      { name: 'Jane Doe', title: 'MP', email: 'jane@parliament.ca' },\n      { name: 'John Smith', title: 'Councillor', email: 'john@city.ca' },\n    ]),\n    sortOrder: 10,\n  },\n});\n

Template Usage:

<ul>\n{{#each REPRESENTATIVES}}\n  <li>\n    <strong>{{name}}</strong> ({{title}})<br>\n    Email: {{email}}\n  </li>\n{{/each}}\n</ul>\n

Data Object:

{\n  REPRESENTATIVES: [\n    { name: 'Jane Doe', title: 'MP', email: 'jane@parliament.ca' },\n    { name: 'John Smith', title: 'Councillor', email: 'john@city.ca' },\n  ],\n}\n

"},{"location":"v2/features/email-templates/variables/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/email-templates/variables/#viewing-variables","title":"Viewing Variables","text":"

From EmailTemplatesPage:

  1. Click Template Row
  2. Opens template detail modal

  3. Navigate to \"Variables\" Tab

  4. Shows table of all variables
  5. Columns: Key, Label, Required, Conditional, Sample Value, Sort Order

  6. Variable Details

  7. Click variable row for description
  8. See where variable is used in template content
  9. View sample value

From EmailTemplateEditorPage:

  1. Open Template Editor
  2. Variables shown in right sidebar

  3. Variable Insertion Panel

  4. Variables listed with labels, badges, descriptions
  5. Sorted by sortOrder ascending
  6. Click \"Insert to HTML/Text\" buttons
"},{"location":"v2/features/email-templates/variables/#adding-variable","title":"Adding Variable","text":"

Step 1: Open Variables Tab - EmailTemplatesPage \u2192 click template \u2192 \"Variables\" tab

Step 2: Click \"Add Variable\" Button - Opens variable creation modal

Step 3: Enter Variable Metadata

Key (required): - Uppercase with underscores (e.g., USER_NAME) - Must be unique within template - Used in template as {{KEY}}

Label (required): - Display name for UI (e.g., \"User's Full Name\") - Human-readable description

Description (optional): - Detailed explanation of variable purpose - Usage notes (e.g., \"Must be in YYYY-MM-DD format\")

Is Required: - Toggle on if variable must always be provided - Validation will fail if missing

Is Conditional: - Toggle on if variable is used in {{#if}} blocks - UI shows blue \"Conditional\" badge

Sample Value (optional): - Example value for testing/preview - Pre-fills test send form - Shows expected data format

Sort Order: - Numeric order for UI display - Lower numbers appear first (1, 2, 3...) - Auto-assigned if not specified

Step 4: Save Variable - Click \"Save\" button - Variable added to template - Available in editor insertion panel

"},{"location":"v2/features/email-templates/variables/#editing-variable","title":"Editing Variable","text":"

Step 1: Open Variables Tab - EmailTemplatesPage \u2192 click template \u2192 \"Variables\" tab

Step 2: Click Variable Row - Opens variable edit modal - Shows current values

Step 3: Modify Fields - Change label, description, flags, sample value - Cannot change key (would break existing templates)

Step 4: Save Changes - Click \"Save\" button - Variable updated in database

Note: Changing variable key requires creating new variable and updating template content manually.

"},{"location":"v2/features/email-templates/variables/#deleting-variable","title":"Deleting Variable","text":"

Step 1: Check Template Usage - Search template content for {{VAR_KEY}} - Ensure variable is not used in subject/HTML/text

Step 2: Click Delete Button - Variables tab \u2192 click variable row \u2192 \"Delete\" button

Step 3: Confirm Deletion - Warning modal: \"Are you sure? This cannot be undone.\" - Click \"Confirm Delete\"

Step 4: Verify Template Still Valid - Open template editor - Check preview renders without errors - Send test email

Warning: Deleting a variable that's still used in template content will cause rendering errors ({{VAR}} will appear as literal text).

"},{"location":"v2/features/email-templates/variables/#reordering-variables","title":"Reordering Variables","text":"

Step 1: Open Variables Tab - EmailTemplatesPage \u2192 click template \u2192 \"Variables\" tab

Step 2: Drag to Reorder - Drag variable rows up/down - Drop to new position

Step 3: Save Sort Order - Click \"Save Order\" button - Updates sortOrder field for all variables

Alternative: Manual Sort Order - Edit variable \u2192 change sortOrder number - Variables re-sort automatically

"},{"location":"v2/features/email-templates/variables/#developer-workflow","title":"Developer Workflow","text":""},{"location":"v2/features/email-templates/variables/#creating-variables-programmatically","title":"Creating Variables Programmatically","text":"

Seed Script Example:

// api/prisma/seed.ts\n\nconst template = await prisma.emailTemplate.findUnique({\n  where: { key: 'shift-signup-confirmation' },\n});\n\nif (!template) throw new Error('Template not found');\n\nconst variables = [\n  {\n    key: 'USER_NAME',\n    label: 'User Name',\n    description: 'Full name of the volunteer',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: 'John Doe',\n    sortOrder: 1,\n  },\n  {\n    key: 'USER_EMAIL',\n    label: 'User Email',\n    description: 'Email address of the volunteer',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: 'john@example.com',\n    sortOrder: 2,\n  },\n  {\n    key: 'SHIFT_TITLE',\n    label: 'Shift Title',\n    description: 'Name of the shift',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: 'Door Knocking - Downtown',\n    sortOrder: 3,\n  },\n  {\n    key: 'SHIFT_DATE',\n    label: 'Shift Date',\n    description: 'Formatted shift date (e.g., \"Saturday, March 15, 2026\")',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: 'Saturday, March 15, 2026',\n    sortOrder: 4,\n  },\n  {\n    key: 'SHIFT_TIME',\n    label: 'Shift Time',\n    description: 'Shift time range (e.g., \"10:00 AM - 2:00 PM\")',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: '10:00 AM - 2:00 PM',\n    sortOrder: 5,\n  },\n  {\n    key: 'SHIFT_LOCATION',\n    label: 'Shift Location',\n    description: 'Meeting location for shift',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: 'Campaign Office (123 Main St)',\n    sortOrder: 6,\n  },\n  {\n    key: 'HAS_PHONE',\n    label: 'Has Phone Number',\n    description: 'Whether user provided a phone number',\n    isRequired: false,\n    isConditional: true,\n    sampleValue: 'true',\n    sortOrder: 7,\n  },\n  {\n    key: 'USER_PHONE',\n    label: 'User Phone',\n    description: 'User phone number (optional)',\n    isRequired: false,\n    isConditional: false,\n    sampleValue: '(555) 123-4567',\n    sortOrder: 8,\n  },\n];\n\n// Upsert variables\nfor (const variable of variables) {\n  await prisma.emailTemplateVariable.upsert({\n    where: {\n      templateId_key: {\n        templateId: template.id,\n        key: variable.key,\n      },\n    },\n    update: {\n      label: variable.label,\n      description: variable.description,\n      isRequired: variable.isRequired,\n      isConditional: variable.isConditional,\n      sampleValue: variable.sampleValue,\n      sortOrder: variable.sortOrder,\n    },\n    create: {\n      templateId: template.id,\n      ...variable,\n    },\n  });\n}\n\nconsole.log(`\u2713 Created ${variables.length} variables for shift-signup-confirmation template`);\n
"},{"location":"v2/features/email-templates/variables/#loading-variables-in-code","title":"Loading Variables in Code","text":"

With Template:

const template = await prisma.emailTemplate.findUnique({\n  where: { key: 'shift-signup-confirmation' },\n  include: { variables: true },\n});\n\nconsole.log('Template variables:', template?.variables);\n

Ordered by Sort:

const template = await prisma.emailTemplate.findUnique({\n  where: { key: 'shift-signup-confirmation' },\n  include: {\n    variables: {\n      orderBy: { sortOrder: 'asc' },\n    },\n  },\n});\n

Required Variables Only:

const requiredVars = await prisma.emailTemplateVariable.findMany({\n  where: {\n    templateId: template.id,\n    isRequired: true,\n  },\n});\n\nconsole.log('Required variables:', requiredVars.map(v => v.key));\n

"},{"location":"v2/features/email-templates/variables/#validating-variables","title":"Validating Variables","text":"

Validation Function:

// api/src/services/email.service.ts\n\nfunction validateVariables(\n  template: EmailTemplate & { variables: EmailTemplateVariable[] },\n  data: Record<string, unknown>\n) {\n  const missing: string[] = [];\n\n  for (const variable of template.variables) {\n    if (variable.isRequired && (data[variable.key] === undefined || data[variable.key] === null)) {\n      missing.push(variable.key);\n    }\n  }\n\n  if (missing.length > 0) {\n    throw new MissingRequiredVariableError(\n      `Missing required variables for template ${template.key}: ${missing.join(', ')}`\n    );\n  }\n}\n

Usage:

const template = await prisma.emailTemplate.findUnique({\n  where: { key: 'shift-reminder' },\n  include: { variables: true },\n});\n\ntry {\n  validateVariables(template, {\n    USER_NAME: 'John Doe',\n    SHIFT_DATE: '2026-03-15',\n    // Missing SHIFT_TITLE (required)\n  });\n} catch (error) {\n  console.error('Validation failed:', error.message);\n  // Error: Missing required variables for template shift-reminder: SHIFT_TITLE\n}\n

"},{"location":"v2/features/email-templates/variables/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/email-templates/variables/#creating-variable-via-api","title":"Creating Variable via API","text":"

Endpoint: POST /api/email-templates/:id/variables

Request Body:

{\n  \"key\": \"USER_NAME\",\n  \"label\": \"User Name\",\n  \"description\": \"Full name of the email recipient\",\n  \"isRequired\": true,\n  \"isConditional\": false,\n  \"sampleValue\": \"John Doe\",\n  \"sortOrder\": 1\n}\n

Route Implementation:

// api/src/modules/email-templates/email-templates.routes.ts\n\nrouter.post('/:id/variables', requireRole(SUPER_ADMIN), async (req, res) => {\n  const { id } = req.params;\n  const { key, label, description, isRequired, isConditional, sampleValue, sortOrder } = req.body;\n\n  try {\n    const variable = await prisma.emailTemplateVariable.create({\n      data: {\n        templateId: id,\n        key,\n        label,\n        description,\n        isRequired: isRequired || false,\n        isConditional: isConditional || false,\n        sampleValue,\n        sortOrder: sortOrder || 0,\n      },\n    });\n\n    res.json(variable);\n  } catch (error: any) {\n    if (error.code === 'P2002') {\n      // Unique constraint violation\n      return res.status(400).json({ error: 'Variable key already exists for this template' });\n    }\n    throw error;\n  }\n});\n

"},{"location":"v2/features/email-templates/variables/#auto-generating-sample-data","title":"Auto-Generating Sample Data","text":"

Load Sample Data from Variables:

function generateSampleData(variables: EmailTemplateVariable[]): Record<string, unknown> {\n  const sampleData: Record<string, unknown> = {};\n\n  for (const variable of variables) {\n    if (variable.sampleValue) {\n      // Try to parse as JSON (for arrays/objects)\n      try {\n        sampleData[variable.key] = JSON.parse(variable.sampleValue);\n      } catch {\n        // Use as string\n        sampleData[variable.key] = variable.sampleValue;\n      }\n    } else if (variable.isConditional) {\n      // Default conditional variables to true\n      sampleData[variable.key] = true;\n    } else {\n      // Default to empty string\n      sampleData[variable.key] = '';\n    }\n  }\n\n  return sampleData;\n}\n

Usage in Editor:

const template = await api.get(`/api/email-templates/${id}`);\nconst sampleData = generateSampleData(template.variables);\n\nsetSampleData(sampleData);\n

"},{"location":"v2/features/email-templates/variables/#variable-usage-detection","title":"Variable Usage Detection","text":"

Find Variables Used in Template Content:

function findUsedVariables(content: string): string[] {\n  // Regex: matches {{VAR}} but not {{#if}}, {{/if}}, {{#each}}, etc.\n  const regex = /\\{\\{(?!#|\\/|\\^)([A-Z_]+)\\}\\}/g;\n  const matches = content.matchAll(regex);\n\n  const variables = new Set<string>();\n  for (const match of matches) {\n    variables.add(match[1]);\n  }\n\n  return Array.from(variables);\n}\n

Check for Unused Variables:

const template = await prisma.emailTemplate.findUnique({\n  where: { id: templateId },\n  include: { variables: true },\n});\n\nconst htmlVars = findUsedVariables(template.htmlContent);\nconst textVars = findUsedVariables(template.textContent);\nconst subjectVars = findUsedVariables(template.subjectLine);\n\nconst usedVars = new Set([...htmlVars, ...textVars, ...subjectVars]);\n\nconst unusedVars = template.variables.filter(v => !usedVars.has(v.key));\n\nconsole.log('Unused variables:', unusedVars.map(v => v.key));\n

"},{"location":"v2/features/email-templates/variables/#common-variables-by-category","title":"Common Variables by Category","text":""},{"location":"v2/features/email-templates/variables/#influence-templates","title":"INFLUENCE Templates","text":"

Standard Variables:

Key Label Required Conditional Description USER_NAME User Name Yes No Participant's full name USER_EMAIL User Email Yes No Participant's email address CAMPAIGN_TITLE Campaign Title Yes No Campaign name CAMPAIGN_SLUG Campaign Slug Yes No URL-safe campaign identifier CAMPAIGN_URL Campaign URL No No Full URL to campaign page REPRESENTATIVE_NAME Representative Name Yes No Representative's full name REPRESENTATIVE_TITLE Representative Title Yes No Representative's title (e.g., \"MP for Downtown\") REPRESENTATIVE_EMAIL Representative Email Yes No Representative's email address CUSTOM_MESSAGE Custom Message Yes No Participant's custom message to representative RESPONSE_TEXT Response Text No No Participant's response wall submission VERIFICATION_LINK Verification Link No No Unique verification URL HAS_CUSTOM_MESSAGE Has Custom Message No Yes Whether participant added custom message

Usage Example:

await emailService.sendFromTemplate('campaign-email', {\n  recipientEmail: representative.email,\n  data: {\n    USER_NAME: participant.name,\n    USER_EMAIL: participant.email,\n    CAMPAIGN_TITLE: campaign.title,\n    CAMPAIGN_SLUG: campaign.slug,\n    REPRESENTATIVE_NAME: representative.name,\n    REPRESENTATIVE_TITLE: representative.title,\n    REPRESENTATIVE_EMAIL: representative.email,\n    CUSTOM_MESSAGE: emailData.customMessage,\n  },\n});\n

"},{"location":"v2/features/email-templates/variables/#map-templates","title":"MAP Templates","text":"

Standard Variables:

Key Label Required Conditional Description USER_NAME User Name Yes No Volunteer's full name USER_EMAIL User Email Yes No Volunteer's email address USER_PHONE User Phone No No Volunteer's phone number (optional) HAS_PHONE Has Phone No Yes Whether user provided phone number SHIFT_TITLE Shift Title Yes No Shift name SHIFT_DATE Shift Date Yes No Formatted shift date SHIFT_TIME Shift Time Yes No Shift time range (e.g., \"10:00 AM - 2:00 PM\") SHIFT_LOCATION Shift Location Yes No Meeting location for shift CUT_NAME Cut Name No No Canvass area name IS_CUT_ASSIGNED Is Cut Assigned No Yes Whether volunteer is assigned to a cut VISIT_COUNT Visit Count No No Number of doors knocked (session summary) CONTACT_COUNT Contact Count No No Number of successful contacts SUPPORT_COUNT Support Count No No Number of supporters identified

Usage Example:

await emailService.sendFromTemplate('shift-signup-confirmation', {\n  recipientEmail: volunteer.email,\n  data: {\n    USER_NAME: volunteer.name,\n    USER_EMAIL: volunteer.email,\n    USER_PHONE: volunteer.phone || '',\n    HAS_PHONE: !!volunteer.phone,\n    SHIFT_TITLE: shift.title,\n    SHIFT_DATE: dayjs(shift.startTime).format('dddd, MMMM D, YYYY'),\n    SHIFT_TIME: `${dayjs(shift.startTime).format('h:mm A')} - ${dayjs(shift.endTime).format('h:mm A')}`,\n    SHIFT_LOCATION: shift.location,\n    IS_CUT_ASSIGNED: !!shift.cutId,\n    CUT_NAME: shift.cut?.name || '',\n  },\n});\n

"},{"location":"v2/features/email-templates/variables/#system-templates","title":"SYSTEM Templates","text":"

Standard Variables:

Key Label Required Conditional Description USER_NAME User Name Yes No User's full name USER_EMAIL User Email Yes No User's email address VERIFICATION_LINK Verification Link No No Unique verification URL (expires 24h) RESET_LINK Reset Link No No Unique password reset URL (expires 1h) SUPPORT_EMAIL Support Email Yes No Platform support email address SITE_NAME Site Name Yes No Platform name (from SiteSettings) SITE_URL Site URL Yes No Platform base URL LOGIN_URL Login URL No No Direct link to login page LOCKOUT_REASON Lockout Reason No No Why account was locked (security)

Usage Example:

await emailService.sendFromTemplate('password-reset', {\n  recipientEmail: user.email,\n  data: {\n    USER_NAME: user.name,\n    USER_EMAIL: user.email,\n    RESET_LINK: `https://cmlite.org/reset-password/${token}`,\n    SUPPORT_EMAIL: siteSettings.supportEmail,\n    SITE_NAME: siteSettings.siteName,\n    SITE_URL: siteSettings.siteUrl,\n  },\n});\n

"},{"location":"v2/features/email-templates/variables/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/email-templates/variables/#problem-variable-not-appearing-in-editor","title":"Problem: Variable not appearing in editor","text":"

Symptoms: - Variable exists in database but not shown in editor insertion panel - Variable missing from variables list

Causes: 1. Variable belongs to different template 2. Template not refreshed after adding variable 3. Sort order is null or very high (out of view)

Solutions:

Check variable exists:

SELECT * FROM email_template_variables\nWHERE template_id = 'cuid123' AND key = 'USER_NAME';\n

Verify template ID:

SELECT id, key FROM email_templates WHERE key = 'shift-reminder';\n-- Check ID matches variable.template_id\n

Refresh editor page: - Hard refresh (Ctrl+Shift+R) - Clear browser cache

Check sort order:

SELECT key, sort_order FROM email_template_variables\nWHERE template_id = 'cuid123'\nORDER BY sort_order;\n\n-- Update if needed\nUPDATE email_template_variables\nSET sort_order = 1\nWHERE id = 'variable-id';\n

"},{"location":"v2/features/email-templates/variables/#problem-validation-error-for-optional-variable","title":"Problem: Validation error for optional variable","text":"

Symptoms: - MissingRequiredVariableError thrown for variable marked as optional - Email send fails unexpectedly

Causes: 1. Variable incorrectly marked as required in database 2. Validation logic bug 3. Template uses variable in required context

Solutions:

Check isRequired flag:

SELECT key, is_required FROM email_template_variables\nWHERE key = 'USER_PHONE' AND template_id = 'cuid123';\n

Update to optional:

UPDATE email_template_variables\nSET is_required = false\nWHERE key = 'USER_PHONE' AND template_id = 'cuid123';\n

Provide variable anyway:

// Temporary fix: always provide optional variables\ndata: {\n  USER_PHONE: volunteer.phone || '',  // Empty string if missing\n}\n

Check validation logic:

// Ensure validation checks for undefined AND null\nif (variable.isRequired && (data[variable.key] === undefined || data[variable.key] === null)) {\n  missing.push(variable.key);\n}\n

"},{"location":"v2/features/email-templates/variables/#problem-sample-value-not-used-in-preview","title":"Problem: Sample value not used in preview","text":"

Symptoms: - Preview shows empty values instead of sample values - Test send form doesn't pre-fill

Causes: 1. Sample value is null in database 2. Sample data initialization bug 3. Variable added after editor loaded

Solutions:

Check sample value exists:

SELECT key, sample_value FROM email_template_variables\nWHERE template_id = 'cuid123';\n

Update sample value:

UPDATE email_template_variables\nSET sample_value = 'John Doe'\nWHERE key = 'USER_NAME';\n

Refresh editor: - Close and reopen EmailTemplateEditorPage - Sample data reloads from variables

Manual preview data:

// Editor UI allows manual editing of sample data\nsetSampleData({\n  ...sampleData,\n  USER_NAME: 'Test Name',\n});\n

"},{"location":"v2/features/email-templates/variables/#problem-duplicate-variable-key-error","title":"Problem: Duplicate variable key error","text":"

Symptoms: - P2002: Unique constraint failed error when creating variable - Cannot add variable with same key

Causes: 1. Variable already exists for this template 2. Attempting to create duplicate

Solutions:

Check existing variables:

SELECT * FROM email_template_variables\nWHERE template_id = 'cuid123' AND key = 'USER_NAME';\n

Update existing instead:

await prisma.emailTemplateVariable.upsert({\n  where: {\n    templateId_key: {\n      templateId: template.id,\n      key: 'USER_NAME',\n    },\n  },\n  update: {\n    label: 'User Full Name',  // Updated label\n  },\n  create: {\n    templateId: template.id,\n    key: 'USER_NAME',\n    label: 'User Full Name',\n    // ...\n  },\n});\n

Use different key:

// If truly need separate variable\nkey: 'USER_FULL_NAME',  // Not USER_NAME\n

"},{"location":"v2/features/email-templates/variables/#problem-variables-not-alphabetically-sorted","title":"Problem: Variables not alphabetically sorted","text":"

Symptoms: - Variables appear in random order in editor - Want alphabetical order instead of custom sort

Causes: - Sort order not set alphabetically - Need to update sortOrder values

Solutions:

Sort alphabetically by key:

-- Generate new sort order based on alphabetical order\nWITH sorted AS (\n  SELECT id, ROW_NUMBER() OVER (PARTITION BY template_id ORDER BY key) AS new_order\n  FROM email_template_variables\n  WHERE template_id = 'cuid123'\n)\nUPDATE email_template_variables\nSET sort_order = sorted.new_order\nFROM sorted\nWHERE email_template_variables.id = sorted.id;\n

Sort by label:

WITH sorted AS (\n  SELECT id, ROW_NUMBER() OVER (PARTITION BY template_id ORDER BY label) AS new_order\n  FROM email_template_variables\n  WHERE template_id = 'cuid123'\n)\nUPDATE email_template_variables\nSET sort_order = sorted.new_order\nFROM sorted\nWHERE email_template_variables.id = sorted.id;\n

Manual custom order: - Use admin UI to drag-drop reorder - Saves custom sortOrder values

"},{"location":"v2/features/email-templates/variables/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/email-templates/variables/#variable-loading","title":"Variable Loading","text":"

Current Implementation: - Variables loaded with template via include: { variables: true } - Single database query (JOIN) - Fast (< 10ms for typical templates)

Optimization for Many Variables:

// If template has 100+ variables, consider pagination\nconst variables = await prisma.emailTemplateVariable.findMany({\n  where: { templateId: template.id },\n  orderBy: { sortOrder: 'asc' },\n  take: 50,  // Load first 50\n  skip: 0,   // Offset for pagination\n});\n

"},{"location":"v2/features/email-templates/variables/#validation-performance","title":"Validation Performance","text":"

Required Variable Check: - O(n) where n = number of required variables - Fast for typical templates (< 10 required vars) - No database queries (uses in-memory variable list)

Caching Variables:

// Cache template + variables to avoid DB lookup per send\nconst templateCache = new Map<string, EmailTemplate & { variables: EmailTemplateVariable[] }>();\n\nasync function loadTemplate(key: string) {\n  if (templateCache.has(key)) {\n    return templateCache.get(key)!;\n  }\n\n  const template = await prisma.emailTemplate.findUnique({\n    where: { key, isActive: true },\n    include: { variables: true },\n  });\n\n  if (template) {\n    templateCache.set(key, template);\n  }\n\n  return template;\n}\n

"},{"location":"v2/features/email-templates/variables/#best-practices","title":"Best Practices","text":""},{"location":"v2/features/email-templates/variables/#variable-naming-conventions","title":"Variable Naming Conventions","text":"

Use UPPERCASE_WITH_UNDERSCORES:

// \u2713 Good\nUSER_NAME\nSHIFT_DATE\nHAS_PHONE\nREPRESENTATIVE_EMAIL\n\n// \u2717 Bad\nuserName      // Not uppercase\nuser-name     // Dashes not underscores\nUserName      // PascalCase\n

Be Descriptive:

// \u2713 Good\nSHIFT_START_TIME\nCAMPAIGN_TITLE\nIS_EMAIL_VERIFIED\n\n// \u2717 Bad\nTIME          // Too vague\nTITLE         // Ambiguous\nVERIFIED      // Missing context\n

Prefix Booleans with IS/HAS:

// \u2713 Good\nHAS_PHONE\nIS_VERIFIED\nIS_CUT_ASSIGNED\n\n// \u2717 Bad\nPHONE         // Not clearly boolean\nVERIFIED      // Ambiguous (boolean or timestamp?)\n

"},{"location":"v2/features/email-templates/variables/#documentation","title":"Documentation","text":"

Always Provide Labels:

// \u2713 Good\nlabel: 'User\\'s Full Name',\ndescription: 'Full name of the email recipient',\n\n// \u2717 Bad\nlabel: 'Name',  // Too generic\ndescription: '',\n

Document Expected Format:

// \u2713 Good\ndescription: 'Shift date in format \"Saturday, March 15, 2026\"',\nsampleValue: 'Saturday, March 15, 2026',\n\n// \u2717 Bad\ndescription: 'The date',\nsampleValue: '2026-03-15',  // Doesn't match expected format\n

"},{"location":"v2/features/email-templates/variables/#sample-values","title":"Sample Values","text":"

Provide Realistic Examples:

// \u2713 Good\nsampleValue: 'John Doe',                      // USER_NAME\nsampleValue: 'Saturday, March 15, 2026',      // SHIFT_DATE\nsampleValue: '(555) 123-4567',                // USER_PHONE\n\n// \u2717 Bad\nsampleValue: 'test',                          // Not realistic\nsampleValue: '123',                           // Not realistic phone\n

Use JSON for Arrays/Objects:

// \u2713 Good\nsampleValue: JSON.stringify([\n  { name: 'Jane Doe', email: 'jane@example.com' },\n  { name: 'John Smith', email: 'john@example.com' },\n]),\n\n// \u2717 Bad\nsampleValue: 'array of representatives',  // Not parseable\n

"},{"location":"v2/features/email-templates/variables/#required-vs-optional","title":"Required vs Optional","text":"

Make Variables Required If: - Used in subject line (always visible) - Critical to email meaning (e.g., event date) - No reasonable default value

Make Variables Optional If: - Used in conditional blocks ({{#if}}) - Nice-to-have but not critical - Has fallback text in template

"},{"location":"v2/features/email-templates/variables/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/email-templates/variables/#frontend-documentation","title":"Frontend Documentation","text":""},{"location":"v2/features/email-templates/variables/#backend-documentation","title":"Backend Documentation","text":""},{"location":"v2/features/email-templates/variables/#database-documentation","title":"Database Documentation","text":""},{"location":"v2/features/email-templates/variables/#feature-documentation","title":"Feature Documentation","text":""},{"location":"v2/features/email-templates/versioning/","title":"Template Version History","text":""},{"location":"v2/features/email-templates/versioning/#overview","title":"Overview","text":"

The Template Version History system provides comprehensive audit trails for email template changes with automatic version creation, rollback capability, and change tracking. Every template save creates a new version snapshot, preserving the complete history of modifications with metadata about who changed what and why.

Key Features:

Benefits:

"},{"location":"v2/features/email-templates/versioning/#architecture","title":"Architecture","text":"
flowchart TB\n    subgraph \"Version Creation Flow\"\n        Save[Admin Saves Template]\n        FindMax[Find Max Version Number]\n        Increment[Increment to Next Version]\n        CreateVersion[Create EmailTemplateVersion]\n        UpdateTemplate[Update EmailTemplate]\n\n        Save --> FindMax\n        FindMax --> Increment\n        Increment --> CreateVersion\n        CreateVersion --> UpdateTemplate\n    end\n\n    subgraph \"Database Models\"\n        Template[(EmailTemplate)]\n        Versions[(EmailTemplateVersion)]\n\n        Template -->|1:N| Versions\n    end\n\n    subgraph \"Version Data\"\n        Snapshot[Content Snapshot<br/>subject, HTML, text]\n        Meta[Metadata<br/>version number, change notes]\n        Attribution[Attribution<br/>created by user, timestamp]\n\n        Snapshot --> Versions\n        Meta --> Versions\n        Attribution --> Versions\n    end\n\n    subgraph \"Version Operations\"\n        List[List Version History]\n        Compare[Compare Two Versions]\n        Rollback[Rollback to Version]\n        View[View Version Details]\n\n        Versions --> List\n        Versions --> Compare\n        Versions --> Rollback\n        Versions --> View\n    end\n\n    subgraph \"Rollback Flow\"\n        SelectVersion[Select Old Version]\n        LoadContent[Load Old Content]\n        UpdateCurrent[Update Current Template]\n        CreateNewVersion[Create New Version<br/>'Rolled back to vX']\n\n        SelectVersion --> LoadContent\n        LoadContent --> UpdateCurrent\n        UpdateCurrent --> CreateNewVersion\n        CreateNewVersion --> Versions\n    end\n\n    Save --> Template\n    CreateVersion --> Versions\n    Rollback --> Template\n\n    style Save fill:#4a90e2,color:#fff\n    style CreateVersion fill:#50c878,color:#fff\n    style Rollback fill:#ff6b6b,color:#fff

Component Responsibilities:

"},{"location":"v2/features/email-templates/versioning/#database-model","title":"Database Model","text":""},{"location":"v2/features/email-templates/versioning/#emailtemplateversion-schema","title":"EmailTemplateVersion Schema","text":"

Table: email_template_versions

Field Type Description id String (CUID) Primary key templateId String Foreign key to EmailTemplate versionNumber Int Auto-incremented version (1, 2, 3...) subjectLine String Subject line snapshot htmlContent Text HTML content snapshot textContent Text Plain text content snapshot changeNotes String (optional) Admin-provided change description createdByUserId String (optional) User who created this version createdAt DateTime Version creation timestamp

Relations: - template \u2014 EmailTemplate (N:1) - createdBy \u2014 User (N:1)

Constraints: - Unique index on (templateId, versionNumber) for version lookup - Auto-increment logic in service layer (finds max + 1) - No ON DELETE CASCADE (preserve versions even if template deleted)

Prisma Schema:

model EmailTemplateVersion {\n  id              String   @id @default(cuid())\n  templateId      String\n  versionNumber   Int\n  subjectLine     String\n  htmlContent     String   @db.Text\n  textContent     String   @db.Text\n  changeNotes     String?\n  createdByUserId String?\n  createdAt       DateTime @default(now())\n\n  template  EmailTemplate @relation(fields: [templateId], references: [id])\n  createdBy User?         @relation(fields: [createdByUserId], references: [id])\n\n  @@unique([templateId, versionNumber])\n  @@index([templateId])\n  @@index([createdAt])\n  @@map(\"email_template_versions\")\n}\n

"},{"location":"v2/features/email-templates/versioning/#version-creation","title":"Version Creation","text":""},{"location":"v2/features/email-templates/versioning/#automatic-versioning-on-save","title":"Automatic Versioning on Save","text":"

When Versions Are Created: - Admin saves template via EmailTemplateEditorPage - API PUT /api/email-templates/:id endpoint called - Version created BEFORE updating template (snapshot current state)

Auto-Increment Logic:

// api/src/modules/email-templates/email-templates.service.ts\n\nasync function createVersion(\n  templateId: string,\n  options: {\n    changeNotes?: string;\n    createdByUserId?: string;\n  }\n) {\n  // 1. Find max version number for this template\n  const maxVersion = await prisma.emailTemplateVersion.findFirst({\n    where: { templateId },\n    orderBy: { versionNumber: 'desc' },\n    select: { versionNumber: true },\n  });\n\n  const nextVersion = (maxVersion?.versionNumber || 0) + 1;\n\n  // 2. Load current template content\n  const template = await prisma.emailTemplate.findUnique({\n    where: { id: templateId },\n  });\n\n  if (!template) {\n    throw new Error('Template not found');\n  }\n\n  // 3. Create version snapshot\n  const version = await prisma.emailTemplateVersion.create({\n    data: {\n      templateId,\n      versionNumber: nextVersion,\n      subjectLine: template.subjectLine,\n      htmlContent: template.htmlContent,\n      textContent: template.textContent,\n      changeNotes: options.changeNotes,\n      createdByUserId: options.createdByUserId,\n    },\n  });\n\n  return version;\n}\n

Save Template with Versioning:

// api/src/modules/email-templates/email-templates.routes.ts\n\nrouter.put('/:id', requireRole(SUPER_ADMIN), async (req, res) => {\n  const { id } = req.params;\n  const { subjectLine, htmlContent, textContent, changeNotes } = req.body;\n\n  try {\n    // 1. Create version BEFORE updating (snapshot current state)\n    await createVersion(id, {\n      changeNotes,\n      createdByUserId: req.user!.id,\n    });\n\n    // 2. Update template with new content\n    const updatedTemplate = await prisma.emailTemplate.update({\n      where: { id },\n      data: {\n        subjectLine,\n        htmlContent,\n        textContent,\n        updatedByUserId: req.user!.id,\n      },\n    });\n\n    res.json(updatedTemplate);\n  } catch (error) {\n    logger.error('Failed to save template', { error, templateId: id });\n    res.status(500).json({ error: 'Failed to save template' });\n  }\n});\n

Important: Version is created BEFORE updating template, so version snapshots the OLD content (not the new content). This preserves the exact state before the change.

"},{"location":"v2/features/email-templates/versioning/#version-number-sequence","title":"Version Number Sequence","text":"

Sequence Rules: - Starts at 1 for first version - Increments by 1 for each save - Per-template sequence (not global) - No gaps in sequence

Example Timeline:

Action Version Subject HTML Change Notes Create template 1 \"Welcome!\" <p>Hello</p> (initial version) Edit subject 2 \"Welcome to Our Platform!\" <p>Hello</p> \"Made subject more descriptive\" Add content 3 \"Welcome to Our Platform!\" <p>Hello {{USER_NAME}}</p> \"Added user name variable\" Rollback to v1 4 \"Welcome!\" <p>Hello</p> \"Rolled back to version 1\"

Note: Rollback creates NEW version (v4 in example), doesn't delete v2 and v3. This preserves complete audit trail.

"},{"location":"v2/features/email-templates/versioning/#change-notes","title":"Change Notes","text":"

Purpose: Describe what changed and why (audit trail documentation)

When Prompted: - EmailTemplateEditorPage shows \"Change Notes\" field on save - Optional but recommended - Stored in changeNotes field

Examples:

Good Change Notes:

- \"Added phone number conditional block\"\n- \"Fixed typo in subject line\"\n- \"Updated shift location variable to include address\"\n- \"Removed deprecated campaign URL variable\"\n- \"Rolled back to version 5 due to rendering issue\"\n

Poor Change Notes:

- \"update\" (not descriptive)\n- \"changes\" (too vague)\n- \"\" (empty, no context)\n

Implementation:

// EmailTemplateEditorPage.tsx\n\nconst [saveModalVisible, setSaveModalVisible] = useState(false);\nconst [changeNotes, setChangeNotes] = useState('');\n\nconst handleSave = async () => {\n  await api.put(`/api/email-templates/${id}`, {\n    subjectLine,\n    htmlContent,\n    textContent,\n    changeNotes: changeNotes || undefined,  // Optional\n  });\n\n  message.success('Template saved successfully');\n  navigate('/app/email-templates');\n};\n\n// Modal UI\n<Modal title=\"Save Template\" visible={saveModalVisible} onOk={handleSave}>\n  <Form.Item label=\"Change Notes (optional)\">\n    <TextArea\n      value={changeNotes}\n      onChange={(e) => setChangeNotes(e.target.value)}\n      placeholder=\"Describe what changed in this version\"\n      rows={4}\n    />\n  </Form.Item>\n</Modal>\n

"},{"location":"v2/features/email-templates/versioning/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/email-templates/versioning/#viewing-version-history","title":"Viewing Version History","text":"

Step 1: Open Template Detail - EmailTemplatesPage \u2192 click template row - Opens template detail modal

Step 2: Navigate to \"Version History\" Tab - Click \"Version History\" tab - Shows table of all versions

Version History Table Columns: - Version \u2014 Version number (e.g., \"v3\") - Created \u2014 Timestamp (e.g., \"2026-03-15 14:23\") - Created By \u2014 User name (e.g., \"John Doe\") - Change Notes \u2014 Description of changes - Actions \u2014 View, Compare, Restore buttons

Sorting: - Default: Descending by version number (newest first) - Can sort by created date or version number

"},{"location":"v2/features/email-templates/versioning/#viewing-version-details","title":"Viewing Version Details","text":"

Step 1: Click \"View\" Button - Version history table \u2192 click version row \u2192 \"View\" button

Step 2: Version Detail Modal - Shows version metadata: - Version number - Created by user - Created timestamp - Change notes - Shows content snapshot: - Subject line - HTML content (scrollable textarea) - Text content (scrollable textarea)

Step 3: Preview Rendered Version - Click \"Preview\" button - Renders HTML with sample data - Shows how email looked at that version

"},{"location":"v2/features/email-templates/versioning/#comparing-versions","title":"Comparing Versions","text":"

Step 1: Select Two Versions - Version history table \u2192 checkbox on two version rows - Click \"Compare Selected\" button

Step 2: Comparison Modal - Side-by-side diff view: - Left: Older version - Right: Newer version - Highlighting: - Green: Added lines - Red: Deleted lines - Yellow: Modified lines

Comparison Sections: - Subject Line Diff \u2014 Shows changes in subject - HTML Content Diff \u2014 Line-by-line HTML diff - Text Content Diff \u2014 Line-by-line text diff

Implementation:

import { diffLines } from 'diff';\n\nfunction renderDiff(oldContent: string, newContent: string) {\n  const diff = diffLines(oldContent, newContent);\n\n  return diff.map((part, index) => {\n    let color = 'black';\n    let backgroundColor = 'transparent';\n\n    if (part.added) {\n      color = 'green';\n      backgroundColor = '#e6ffed';\n    } else if (part.removed) {\n      color = 'red';\n      backgroundColor = '#ffebe9';\n    }\n\n    return (\n      <pre\n        key={index}\n        style={{\n          color,\n          backgroundColor,\n          margin: 0,\n          padding: '2px 4px',\n          fontFamily: 'monospace',\n          fontSize: 12,\n        }}\n      >\n        {part.value}\n      </pre>\n    );\n  });\n}\n

"},{"location":"v2/features/email-templates/versioning/#rolling-back-to-previous-version","title":"Rolling Back to Previous Version","text":"

Step 1: Select Version to Restore - Version history table \u2192 click version row

Step 2: Click \"Restore\" Button - Opens confirmation modal

Step 3: Confirm Rollback - Modal shows: - Version being restored (e.g., \"Version 5\") - Warning: \"This will create a new version with this content\" - Change notes field (pre-filled: \"Rolled back to version 5\")

Step 4: Confirm and Save - Click \"Confirm Restore\" - Creates new version (e.g., v10) with content from v5 - Current template updated to v5 content - Redirects to EmailTemplatesPage

Rollback Process:

// api/src/modules/email-templates/email-templates.routes.ts\n\nrouter.post('/:id/rollback/:versionNumber', requireRole(SUPER_ADMIN), async (req, res) => {\n  const { id } = req.params;\n  const versionNumber = parseInt(req.params.versionNumber);\n\n  try {\n    // 1. Load version to restore\n    const versionToRestore = await prisma.emailTemplateVersion.findUnique({\n      where: {\n        templateId_versionNumber: {\n          templateId: id,\n          versionNumber,\n        },\n      },\n    });\n\n    if (!versionToRestore) {\n      return res.status(404).json({ error: 'Version not found' });\n    }\n\n    // 2. Create version snapshot BEFORE rollback (current state)\n    await createVersion(id, {\n      changeNotes: `Rolled back to version ${versionNumber}`,\n      createdByUserId: req.user!.id,\n    });\n\n    // 3. Update template with old version content\n    const updatedTemplate = await prisma.emailTemplate.update({\n      where: { id },\n      data: {\n        subjectLine: versionToRestore.subjectLine,\n        htmlContent: versionToRestore.htmlContent,\n        textContent: versionToRestore.textContent,\n        updatedByUserId: req.user!.id,\n      },\n    });\n\n    res.json(updatedTemplate);\n  } catch (error) {\n    logger.error('Failed to rollback template', { error, templateId: id, versionNumber });\n    res.status(500).json({ error: 'Failed to rollback template' });\n  }\n});\n

Important: Rollback is non-destructive. It doesn't delete newer versions; it creates a NEW version with old content. This preserves the complete audit trail.

"},{"location":"v2/features/email-templates/versioning/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/email-templates/versioning/#creating-version-manually","title":"Creating Version Manually","text":"

When to Use: - Seed script initialization - Programmatic template updates - Testing version history

Example:

// Create initial version for new template\nconst template = await prisma.emailTemplate.create({\n  data: {\n    key: 'user-welcome',\n    name: 'Welcome Email',\n    category: 'SYSTEM',\n    subjectLine: 'Welcome!',\n    htmlContent: '<p>Hello {{USER_NAME}}</p>',\n    textContent: 'Hello {{USER_NAME}}',\n    isActive: true,\n  },\n});\n\n// Create version 1\nawait prisma.emailTemplateVersion.create({\n  data: {\n    templateId: template.id,\n    versionNumber: 1,\n    subjectLine: template.subjectLine,\n    htmlContent: template.htmlContent,\n    textContent: template.textContent,\n    changeNotes: 'Initial template creation',\n    createdByUserId: adminUser.id,\n  },\n});\n

"},{"location":"v2/features/email-templates/versioning/#loading-version-history","title":"Loading Version History","text":"

Fetch All Versions:

const versions = await prisma.emailTemplateVersion.findMany({\n  where: { templateId },\n  orderBy: { versionNumber: 'desc' },\n  include: {\n    createdBy: {\n      select: { name: true, email: true },\n    },\n  },\n});\n\nconsole.log('Version history:', versions);\n

Fetch Specific Version:

const version = await prisma.emailTemplateVersion.findUnique({\n  where: {\n    templateId_versionNumber: {\n      templateId: 'cuid123',\n      versionNumber: 5,\n    },\n  },\n});\n

Fetch Latest Version:

const latestVersion = await prisma.emailTemplateVersion.findFirst({\n  where: { templateId },\n  orderBy: { versionNumber: 'desc' },\n});\n\nconsole.log('Latest version:', latestVersion.versionNumber);\n

"},{"location":"v2/features/email-templates/versioning/#version-diff-generation","title":"Version Diff Generation","text":"

Line-by-Line Diff:

import { diffLines, Change } from 'diff';\n\ninterface VersionDiff {\n  subject: Change[];\n  html: Change[];\n  text: Change[];\n}\n\nfunction compareVersions(\n  oldVersion: EmailTemplateVersion,\n  newVersion: EmailTemplateVersion\n): VersionDiff {\n  return {\n    subject: diffLines(oldVersion.subjectLine, newVersion.subjectLine),\n    html: diffLines(oldVersion.htmlContent, newVersion.htmlContent),\n    text: diffLines(oldVersion.textContent, newVersion.textContent),\n  };\n}\n

Usage:

const version5 = await prisma.emailTemplateVersion.findUnique({\n  where: { templateId_versionNumber: { templateId, versionNumber: 5 } },\n});\n\nconst version6 = await prisma.emailTemplateVersion.findUnique({\n  where: { templateId_versionNumber: { templateId, versionNumber: 6 } },\n});\n\nconst diff = compareVersions(version5, version6);\n\nconsole.log('Subject changes:', diff.subject);\nconsole.log('HTML changes:', diff.html);\nconsole.log('Text changes:', diff.text);\n

Render Diff in UI:

// admin/src/components/VersionDiff.tsx\n\nimport { diffLines } from 'diff';\n\ninterface VersionDiffProps {\n  oldContent: string;\n  newContent: string;\n  title: string;\n}\n\nexport function VersionDiff({ oldContent, newContent, title }: VersionDiffProps) {\n  const diff = diffLines(oldContent, newContent);\n\n  return (\n    <div>\n      <h4>{title}</h4>\n      <pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: 12 }}>\n        {diff.map((part, index) => {\n          let style = {};\n\n          if (part.added) {\n            style = { color: 'green', backgroundColor: '#e6ffed' };\n          } else if (part.removed) {\n            style = { color: 'red', backgroundColor: '#ffebe9' };\n          }\n\n          return (\n            <span key={index} style={style}>\n              {part.value}\n            </span>\n          );\n        })}\n      </pre>\n    </div>\n  );\n}\n

"},{"location":"v2/features/email-templates/versioning/#rollback-api-implementation","title":"Rollback API Implementation","text":"

Full Rollback Route:

// api/src/modules/email-templates/email-templates.routes.ts\n\nimport { Router } from 'express';\nimport { requireRole } from '@/middleware/auth';\nimport { prisma } from '@/config/database';\nimport { logger } from '@/utils/logger';\n\nconst router = Router();\n\nrouter.post('/:id/rollback/:versionNumber', requireRole('SUPER_ADMIN'), async (req, res) => {\n  const { id } = req.params;\n  const versionNumber = parseInt(req.params.versionNumber, 10);\n\n  if (isNaN(versionNumber) || versionNumber < 1) {\n    return res.status(400).json({ error: 'Invalid version number' });\n  }\n\n  try {\n    // 1. Load version to restore\n    const versionToRestore = await prisma.emailTemplateVersion.findUnique({\n      where: {\n        templateId_versionNumber: {\n          templateId: id,\n          versionNumber,\n        },\n      },\n    });\n\n    if (!versionToRestore) {\n      return res.status(404).json({ error: 'Version not found' });\n    }\n\n    // 2. Load current template\n    const currentTemplate = await prisma.emailTemplate.findUnique({\n      where: { id },\n    });\n\n    if (!currentTemplate) {\n      return res.status(404).json({ error: 'Template not found' });\n    }\n\n    // 3. Check if already at this version (no-op)\n    if (\n      currentTemplate.subjectLine === versionToRestore.subjectLine &&\n      currentTemplate.htmlContent === versionToRestore.htmlContent &&\n      currentTemplate.textContent === versionToRestore.textContent\n    ) {\n      return res.status(400).json({ error: 'Template already matches this version' });\n    }\n\n    // 4. Use transaction for atomicity\n    await prisma.$transaction(async (tx) => {\n      // 4a. Create version snapshot of CURRENT state\n      const maxVersion = await tx.emailTemplateVersion.findFirst({\n        where: { templateId: id },\n        orderBy: { versionNumber: 'desc' },\n        select: { versionNumber: true },\n      });\n\n      const nextVersion = (maxVersion?.versionNumber || 0) + 1;\n\n      await tx.emailTemplateVersion.create({\n        data: {\n          templateId: id,\n          versionNumber: nextVersion,\n          subjectLine: currentTemplate.subjectLine,\n          htmlContent: currentTemplate.htmlContent,\n          textContent: currentTemplate.textContent,\n          changeNotes: `Rolled back to version ${versionNumber}`,\n          createdByUserId: req.user!.id,\n        },\n      });\n\n      // 4b. Update template with OLD version content\n      await tx.emailTemplate.update({\n        where: { id },\n        data: {\n          subjectLine: versionToRestore.subjectLine,\n          htmlContent: versionToRestore.htmlContent,\n          textContent: versionToRestore.textContent,\n          updatedByUserId: req.user!.id,\n        },\n      });\n    });\n\n    // 5. Load updated template\n    const updatedTemplate = await prisma.emailTemplate.findUnique({\n      where: { id },\n    });\n\n    logger.info('Template rolled back', {\n      templateId: id,\n      toVersion: versionNumber,\n      userId: req.user!.id,\n    });\n\n    res.json(updatedTemplate);\n  } catch (error: any) {\n    logger.error('Failed to rollback template', {\n      error: error.message,\n      templateId: id,\n      versionNumber,\n    });\n    res.status(500).json({ error: 'Failed to rollback template' });\n  }\n});\n\nexport default router;\n
"},{"location":"v2/features/email-templates/versioning/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/email-templates/versioning/#problem-version-numbers-not-auto-incrementing","title":"Problem: Version numbers not auto-incrementing","text":"

Symptoms: - Duplicate version number error - P2002: Unique constraint failed on templateId_versionNumber

Causes: 1. Race condition (two saves at same time) 2. Max version query returns wrong result 3. Database constraint violated

Solutions:

Check max version:

SELECT MAX(version_number) FROM email_template_versions\nWHERE template_id = 'cuid123';\n

Use transaction for atomicity:

await prisma.$transaction(async (tx) => {\n  // 1. Find max version\n  const maxVersion = await tx.emailTemplateVersion.findFirst({\n    where: { templateId },\n    orderBy: { versionNumber: 'desc' },\n  });\n\n  const nextVersion = (maxVersion?.versionNumber || 0) + 1;\n\n  // 2. Create version (within same transaction)\n  await tx.emailTemplateVersion.create({\n    data: {\n      templateId,\n      versionNumber: nextVersion,\n      // ...\n    },\n  });\n});\n

Reset sequence if needed:

-- Check for gaps\nSELECT version_number FROM email_template_versions\nWHERE template_id = 'cuid123'\nORDER BY version_number;\n\n-- If gaps exist, renumber (DANGEROUS, only in dev)\nUPDATE email_template_versions\nSET version_number = (\n  SELECT COUNT(*) FROM email_template_versions AS v2\n  WHERE v2.template_id = email_template_versions.template_id\n    AND v2.created_at <= email_template_versions.created_at\n)\nWHERE template_id = 'cuid123';\n

"},{"location":"v2/features/email-templates/versioning/#problem-rollback-creates-infinite-versions","title":"Problem: Rollback creates infinite versions","text":"

Symptoms: - Rollback triggers another rollback - Version numbers increment rapidly

Causes: 1. Rollback doesn't use transaction 2. Version creation triggers template update hook

Solutions:

Use atomic transaction:

await prisma.$transaction(async (tx) => {\n  // Create version + update template in same transaction\n  await tx.emailTemplateVersion.create({ ... });\n  await tx.emailTemplate.update({ ... });\n});\n

Disable hooks during rollback:

// If using Prisma middleware, skip version creation during rollback\nprisma.$use(async (params, next) => {\n  if (params.model === 'EmailTemplate' && params.action === 'update') {\n    // Check if this is a rollback operation (via context flag)\n    if (params.args.data._isRollback) {\n      delete params.args.data._isRollback;\n      return next(params);  // Skip version creation\n    }\n\n    // Normal update: create version\n    await createVersion(params.args.where.id);\n  }\n\n  return next(params);\n});\n

"},{"location":"v2/features/email-templates/versioning/#problem-version-history-shows-duplicate-content","title":"Problem: Version history shows duplicate content","text":"

Symptoms: - Multiple versions with identical content - Version numbers increment but content unchanged

Causes: 1. Save triggered multiple times (double-click) 2. No dirty check before saving

Solutions:

Add content comparison before save:

router.put('/:id', requireRole(SUPER_ADMIN), async (req, res) => {\n  const { id } = req.params;\n  const { subjectLine, htmlContent, textContent, changeNotes } = req.body;\n\n  // 1. Load current template\n  const currentTemplate = await prisma.emailTemplate.findUnique({ where: { id } });\n\n  // 2. Check if content changed\n  if (\n    currentTemplate.subjectLine === subjectLine &&\n    currentTemplate.htmlContent === htmlContent &&\n    currentTemplate.textContent === textContent\n  ) {\n    return res.status(400).json({ error: 'No changes detected' });\n  }\n\n  // 3. Create version + update template\n  await createVersion(id, { changeNotes, createdByUserId: req.user!.id });\n  await prisma.emailTemplate.update({ where: { id }, data: { subjectLine, htmlContent, textContent } });\n\n  res.json({ success: true });\n});\n

Debounce save button:

// EmailTemplateEditorPage.tsx\n\nconst [saving, setSaving] = useState(false);\n\nconst handleSave = async () => {\n  if (saving) return;  // Prevent double-click\n\n  setSaving(true);\n  try {\n    await api.put(`/api/email-templates/${id}`, { ... });\n  } finally {\n    setSaving(false);\n  }\n};\n

"},{"location":"v2/features/email-templates/versioning/#problem-version-comparison-shows-no-diff","title":"Problem: Version comparison shows no diff","text":"

Symptoms: - Comparison modal shows identical content - No green/red highlighting

Causes: 1. Comparing version with itself 2. Versions truly identical (duplicate save)

Solutions:

Prevent self-comparison:

function handleCompare(version1: number, version2: number) {\n  if (version1 === version2) {\n    message.error('Cannot compare version with itself');\n    return;\n  }\n\n  // Load and compare versions...\n}\n

Check versions exist:

SELECT version_number, LENGTH(html_content) AS html_length\nFROM email_template_versions\nWHERE template_id = 'cuid123'\n  AND version_number IN (5, 6);\n

"},{"location":"v2/features/email-templates/versioning/#problem-rollback-doesnt-restore-variables","title":"Problem: Rollback doesn't restore variables","text":"

Symptoms: - Template content rolled back - Variables not restored (still showing new variables)

Causes: - Variables stored separately (not in version snapshot)

Current Limitation: - EmailTemplateVersion only stores content (subject, HTML, text) - Does NOT store variable definitions - Rolling back template doesn't affect variables

Workaround: - Manually restore variables via admin UI - Future enhancement: Version variable definitions too

Future Enhancement:

// Add to EmailTemplateVersion model\nvariablesSnapshot: Prisma.JsonValue  // JSON array of variables\n\n// When creating version, snapshot variables\nconst variables = await prisma.emailTemplateVariable.findMany({\n  where: { templateId },\n});\n\nawait prisma.emailTemplateVersion.create({\n  data: {\n    // ...\n    variablesSnapshot: variables as unknown as Prisma.InputJsonValue,\n  },\n});\n\n// When rolling back, restore variables\nconst variablesSnapshot = versionToRestore.variablesSnapshot as EmailTemplateVariable[];\n\nfor (const variable of variablesSnapshot) {\n  await prisma.emailTemplateVariable.upsert({\n    where: { templateId_key: { templateId, key: variable.key } },\n    update: variable,\n    create: { templateId, ...variable },\n  });\n}\n

"},{"location":"v2/features/email-templates/versioning/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/email-templates/versioning/#version-storage-growth","title":"Version Storage Growth","text":"

Storage Impact: - Each version stores 3 text fields (subject, HTML, text) - Typical template: 5-20KB per version - 100 versions = 500KB - 2MB per template

Optimization Options:

1. Compress Old Versions:

import zlib from 'zlib';\n\n// Compress HTML content before storing\nconst compressedHtml = zlib.gzipSync(htmlContent).toString('base64');\n\nawait prisma.emailTemplateVersion.create({\n  data: {\n    // ...\n    htmlContent: compressedHtml,\n    isCompressed: true,  // Add flag\n  },\n});\n\n// Decompress when loading\nif (version.isCompressed) {\n  const buffer = Buffer.from(version.htmlContent, 'base64');\n  const htmlContent = zlib.gunzipSync(buffer).toString('utf-8');\n}\n

2. Archive Old Versions:

-- Move versions > 1 year old to archive table\nINSERT INTO email_template_versions_archive\nSELECT * FROM email_template_versions\nWHERE created_at < NOW() - INTERVAL '1 year';\n\nDELETE FROM email_template_versions\nWHERE created_at < NOW() - INTERVAL '1 year';\n

3. Limit Version History:

// Keep only last 50 versions per template\nconst oldVersions = await prisma.emailTemplateVersion.findMany({\n  where: { templateId },\n  orderBy: { versionNumber: 'desc' },\n  skip: 50,  // Skip first 50 (keep these)\n});\n\n// Delete versions beyond 50\nawait prisma.emailTemplateVersion.deleteMany({\n  where: {\n    id: { in: oldVersions.map(v => v.id) },\n  },\n});\n

"},{"location":"v2/features/email-templates/versioning/#version-diff-performance","title":"Version Diff Performance","text":"

Performance Impact: - Diff generation is CPU-intensive for large templates - diffLines algorithm is O(n*m) where n, m = line counts

Optimization:

1. Cache Diff Results:

const diffCache = new Map<string, Change[]>();\n\nfunction getCachedDiff(oldContent: string, newContent: string): Change[] {\n  const cacheKey = `${hashString(oldContent)}-${hashString(newContent)}`;\n\n  if (diffCache.has(cacheKey)) {\n    return diffCache.get(cacheKey)!;\n  }\n\n  const diff = diffLines(oldContent, newContent);\n  diffCache.set(cacheKey, diff);\n\n  return diff;\n}\n

2. Limit Diff Size:

// For very large templates, show summary instead of full diff\nif (oldContent.length > 100000 || newContent.length > 100000) {\n  return {\n    error: 'Template too large for diff. Use version preview instead.',\n  };\n}\n

"},{"location":"v2/features/email-templates/versioning/#best-practices","title":"Best Practices","text":""},{"location":"v2/features/email-templates/versioning/#change-notes-guidelines","title":"Change Notes Guidelines","text":"

Always Provide Change Notes: - Documents WHY changes were made (not just WHAT) - Helps future admins understand context - Useful for compliance audits

Be Specific:

\u2713 Good:\n  - \"Added USER_PHONE variable with conditional block\"\n  - \"Fixed typo in subject line (Welcome vs Welcom)\"\n  - \"Updated shift location to include full address\"\n\n\u2717 Bad:\n  - \"updates\"\n  - \"changes\"\n  - \"fix\"\n

Reference Issues/Tickets:

\"Fixed rendering issue in Gmail (Ticket #123)\"\n\"Added new variable per Sarah's request\"\n

"},{"location":"v2/features/email-templates/versioning/#rollback-safety","title":"Rollback Safety","text":"

Always Review Before Rollback: - View version content before restoring - Compare with current version - Understand what will change

Use Change Notes:

\"Rolled back to version 5 - version 6 broke email rendering in Outlook\"\n

Test After Rollback: - Send test email after rollback - Verify rendering correct - Check all variables still work

"},{"location":"v2/features/email-templates/versioning/#version-retention","title":"Version Retention","text":"

Keep All Versions (Default): - Complete audit trail - Compliance requirements

Archive Old Versions (Optional): - Templates with 100+ versions - Versions older than 1 year - Move to separate archive table

Never Delete Versions: - Breaks audit trail - May violate compliance requirements - Disk space is cheap

"},{"location":"v2/features/email-templates/versioning/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/email-templates/versioning/#frontend-documentation","title":"Frontend Documentation","text":""},{"location":"v2/features/email-templates/versioning/#backend-documentation","title":"Backend Documentation","text":""},{"location":"v2/features/email-templates/versioning/#database-documentation","title":"Database Documentation","text":""},{"location":"v2/features/email-templates/versioning/#feature-documentation","title":"Feature Documentation","text":""},{"location":"v2/features/influence/","title":"Influence Module","text":"

The Influence module provides a complete advocacy campaign platform for email campaigns, representative lookup, response walls, and engagement tracking. It enables supporters to contact their elected officials on issues that matter.

"},{"location":"v2/features/influence/#overview","title":"Overview","text":"

The Influence module consists of five integrated components:

  1. Campaigns - Create and manage advocacy email campaigns
  2. Representatives - Lookup representatives by postal code
  3. Postal Codes - Postal code caching service
  4. Email Queue - Async email sending with BullMQ
  5. Responses - Public response wall with moderation
"},{"location":"v2/features/influence/#features","title":"Features","text":""},{"location":"v2/features/influence/#campaign-management","title":"Campaign Management","text":""},{"location":"v2/features/influence/#representative-lookup","title":"Representative Lookup","text":""},{"location":"v2/features/influence/#email-sending","title":"Email Sending","text":""},{"location":"v2/features/influence/#response-wall","title":"Response Wall","text":""},{"location":"v2/features/influence/#user-flow","title":"User Flow","text":""},{"location":"v2/features/influence/#public-user-experience","title":"Public User Experience","text":"
  1. Browse Campaigns (/campaigns)
  2. View featured campaigns
  3. Search and filter (future)
  4. Click campaign to learn more

  5. Campaign Detail (/campaigns/:id)

  6. Read campaign description
  7. Enter postal code
  8. View matched representatives
  9. Customize email message
  10. Send email

  11. Response Wall (/responses/:campaignId)

  12. Submit public response
  13. Verify email address
  14. View verified responses
  15. Upvote responses
"},{"location":"v2/features/influence/#admin-experience","title":"Admin Experience","text":"
  1. Campaign Management (/app/influence/campaigns)
  2. Create campaigns
  3. Edit templates
  4. Configure targeting
  5. View statistics
  6. Manage visibility

  7. Response Moderation (/app/influence/responses)

  8. Review submissions
  9. Verify/reject responses
  10. Export data
  11. Monitor engagement

  12. Representative Cache (/app/influence/representatives)

  13. View cached representatives
  14. Refresh cache
  15. Monitor lookup statistics

  16. Email Queue (/app/influence/email-queue)

  17. Monitor queue status
  18. View failed jobs
  19. Retry failed emails
  20. Pause/resume queue
"},{"location":"v2/features/influence/#architecture","title":"Architecture","text":""},{"location":"v2/features/influence/#backend-components","title":"Backend Components","text":"

Modules: - api/src/modules/influence/campaigns/ - Campaign CRUD + public routes - api/src/modules/influence/representatives/ - Represent API integration - api/src/modules/influence/postal-codes/ - Postal code cache service - api/src/modules/influence/responses/ - Response CRUD + verification - api/src/modules/influence/campaign-emails/ - Email tracking - api/src/modules/influence/email-queue/ - Queue admin routes

Services: - api/src/services/email.service.ts - Nodemailer wrapper - api/src/services/email-queue.service.ts - BullMQ queue + worker

Database Models: - Campaign - Campaign definitions - CampaignEmail - Sent email tracking - Response - Public response submissions - PostalCodeCache - Cached representative data

"},{"location":"v2/features/influence/#frontend-components","title":"Frontend Components","text":"

Admin Pages: - admin/src/pages/CampaignsPage.tsx - Campaign management - admin/src/pages/ResponsesPage.tsx - Response moderation - admin/src/pages/RepresentativesPage.tsx - Cache admin - admin/src/pages/EmailQueuePage.tsx - Queue monitoring

Public Pages: - admin/src/pages/public/CampaignsListPage.tsx - Campaign listing - admin/src/pages/public/CampaignPage.tsx - Campaign detail + email form - admin/src/pages/public/ResponseWallPage.tsx - Response submissions

"},{"location":"v2/features/influence/#configuration","title":"Configuration","text":""},{"location":"v2/features/influence/#environment-variables","title":"Environment Variables","text":"
# Email\nEMAIL_TEST_MODE=true          # Use MailHog instead of SMTP\nSMTP_HOST=smtp.example.com\nSMTP_PORT=587\nSMTP_USER=user@example.com\nSMTP_PASS=password\n\n# Represent API (optional)\nREPRESENT_API_KEY=your_api_key\n\n# Redis (required for BullMQ)\nREDIS_PASSWORD=your_password\n
"},{"location":"v2/features/influence/#feature-flags","title":"Feature Flags","text":"

Email sending can be toggled via EMAIL_TEST_MODE: - true - Emails sent to MailHog (localhost:8025) - false - Emails sent via SMTP

"},{"location":"v2/features/influence/#integration-points","title":"Integration Points","text":""},{"location":"v2/features/influence/#represent-api","title":"Represent API","text":"

Represent API (https://represent.opennorth.ca/) provides: - Federal MP lookup by postal code - Provincial MLA/MPP lookup - District boundaries - Representative contact info

Rate Limits: 60 requests/minute

Caching Strategy: - Cache postal code \u2192 representative mappings - Refresh cache on 404 (postal code not found) - Cache expiration: 30 days

"},{"location":"v2/features/influence/#listmonk-newsletter-sync","title":"Listmonk Newsletter Sync","text":"

Campaign participants can be synced to Listmonk: - Email submissions \u2192 subscribers - Campaign \u2192 list assignment - Opt-in sync via LISTMONK_SYNC_ENABLED

"},{"location":"v2/features/influence/#email-queue-bullmq","title":"Email Queue (BullMQ)","text":"

BullMQ provides: - Async email processing - Job retry with exponential backoff - Queue monitoring and statistics - Job persistence in Redis

"},{"location":"v2/features/influence/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/influence/#public-endpoints","title":"Public Endpoints","text":"
GET  /api/campaigns/public              # List public campaigns\nGET  /api/campaigns/public/:id          # Get campaign details\nPOST /api/campaigns/:id/send-email      # Send campaign email\nGET  /api/representatives/:postalCode   # Lookup representatives\nPOST /api/responses                     # Submit response\nGET  /api/responses/verify/:token       # Verify email\nGET  /api/responses/campaign/:id        # Get campaign responses\nPOST /api/responses/:id/upvote          # Upvote response\n
"},{"location":"v2/features/influence/#admin-endpoints","title":"Admin Endpoints","text":"
GET    /api/campaigns                   # List all campaigns\nPOST   /api/campaigns                   # Create campaign\nGET    /api/campaigns/:id               # Get campaign\nPATCH  /api/campaigns/:id               # Update campaign\nDELETE /api/campaigns/:id               # Delete campaign\nGET    /api/campaigns/:id/emails        # Get campaign emails\nGET    /api/responses                   # List responses (admin)\nPATCH  /api/responses/:id               # Update response\nDELETE /api/responses/:id               # Delete response\nGET    /api/representatives/cache       # View cache\nPOST   /api/representatives/cache/refresh # Refresh cache\nGET    /api/email-queue/stats           # Queue statistics\nPOST   /api/email-queue/pause           # Pause queue\nPOST   /api/email-queue/resume          # Resume queue\n
"},{"location":"v2/features/influence/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/influence/campaigns/","title":"Campaign Management System","text":""},{"location":"v2/features/influence/campaigns/#overview","title":"Overview","text":"

The campaign management system is the core of Changemaker Lite's advocacy email platform. It enables organizations to create, configure, and manage advocacy campaigns that allow supporters to contact elected representatives via email. The system supports multiple campaign types, customizable features via feature flags, and a complete lifecycle from draft to archived status.

Key Capabilities:

Use Cases:

"},{"location":"v2/features/influence/campaigns/#architecture","title":"Architecture","text":"
graph TD\n    A[Admin User] -->|Creates Campaign| B[CampaignsPage]\n    B -->|POST /api/campaigns| C[Campaign Service]\n    C -->|Save| D[(Campaign Model)]\n\n    E[Public User] -->|Browses| F[CampaignsListPage]\n    F -->|GET /api/public/campaigns| C\n\n    E -->|Views Campaign| G[CampaignPage]\n    G -->|GET /api/public/campaigns/:slug| C\n    G -->|Lookup Reps| H[Representatives Service]\n    G -->|Send Email| I[Email Queue Service]\n    I -->|Add Job| J[(BullMQ Redis)]\n\n    K[Email Worker] -->|Process Jobs| J\n    K -->|Send SMTP| L[Email Recipients]\n    K -->|Track| M[(CampaignEmail Model)]\n\n    D -->|1:N| M\n    D -->|1:N| N[(Response Model)]\n\n    style D fill:#e1f5ff\n    style M fill:#e1f5ff\n    style N fill:#e1f5ff\n    style J fill:#fff4e1

Flow Description:

  1. Admin creates campaign \u2192 Campaign service validates and saves to database
  2. Public user browses \u2192 Campaign service returns active campaigns
  3. User views campaign \u2192 Representatives service looks up postal code
  4. User sends email \u2192 Email queue service adds job to BullMQ
  5. Worker processes job \u2192 Email sent via SMTP, tracked in CampaignEmail model
  6. User submits response \u2192 Response service creates response for moderation
"},{"location":"v2/features/influence/campaigns/#database-models","title":"Database Models","text":""},{"location":"v2/features/influence/campaigns/#campaign-model","title":"Campaign Model","text":"

See Campaign Model Documentation for full schema.

Key Fields:

Feature Flags (12 total):

Flag Type Default Description allowSmtpEmail boolean true Enable email sending allowCallTracking boolean false Enable phone call logging showResponseWall boolean true Display response wall requireEmailVerification boolean true Verify response emails allowAnonymousResponses boolean false Allow responses without login highlightCampaign boolean false Feature on homepage showProgressBar boolean true Display response count progress allowSharing boolean true Enable social sharing buttons requirePostalCode boolean true Require postal code for lookup allowCustomMessage boolean true Users can edit email text trackEmailOpens boolean false Track email opens (future) notifyOnResponse boolean true Email admin on new responses

Related Models:

"},{"location":"v2/features/influence/campaigns/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/influence/campaigns/#admin-endpoints","title":"Admin Endpoints","text":"

See Campaigns Module API Reference for full details.

Method Endpoint Auth Description GET /api/campaigns SUPER_ADMIN, INFLUENCE_ADMIN List all campaigns (paginated) GET /api/campaigns/:id SUPER_ADMIN, INFLUENCE_ADMIN Get campaign details POST /api/campaigns SUPER_ADMIN, INFLUENCE_ADMIN Create new campaign PUT /api/campaigns/:id SUPER_ADMIN, INFLUENCE_ADMIN Update campaign PATCH /api/campaigns/:id/status SUPER_ADMIN, INFLUENCE_ADMIN Update campaign status DELETE /api/campaigns/:id SUPER_ADMIN Delete campaign"},{"location":"v2/features/influence/campaigns/#public-endpoints","title":"Public Endpoints","text":"

See Campaigns Public API Reference.

Method Endpoint Auth Description GET /api/public/campaigns None List active campaigns GET /api/public/campaigns/:slug None Get campaign by slug"},{"location":"v2/features/influence/campaigns/#configuration","title":"Configuration","text":""},{"location":"v2/features/influence/campaigns/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description EMAIL_TEST_MODE boolean false Send emails to MailHog instead of SMTP SMTP_HOST string - SMTP server hostname SMTP_PORT number 587 SMTP server port SMTP_USER string - SMTP username SMTP_PASS string - SMTP password SMTP_FROM_EMAIL string - Default sender email SMTP_FROM_NAME string - Default sender name"},{"location":"v2/features/influence/campaigns/#site-settings","title":"Site Settings","text":"

SMTP settings can be configured via Site Settings (overrides env vars):

{\n  smtpHost: string | null,\n  smtpPort: number | null,\n  smtpUser: string | null,\n  smtpPass: string | null,\n  smtpFromEmail: string | null,\n  smtpFromName: string | null\n}\n
"},{"location":"v2/features/influence/campaigns/#upload-configuration","title":"Upload Configuration","text":"

Cover photos uploaded to /uploads/campaigns/{campaignId}/{filename}.

Limits: - Max file size: 10MB - Allowed formats: jpg, jpeg, png, gif, webp

"},{"location":"v2/features/influence/campaigns/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/influence/campaigns/#1-create-campaign","title":"1. Create Campaign","text":"

[Screenshot: CampaignsPage with \"Create Campaign\" button]

Steps:

  1. Navigate to Influence > Campaigns
  2. Click Create Campaign button
  3. Fill in campaign details:
  4. Title (required)
  5. Description (required)
  6. Target government levels (select all that apply)
  7. Email subject template (use {{VAR}} for dynamic content)
  8. Email body template (HTML supported)
  9. Upload cover photo (optional)
  10. Click Save (saves as DRAFT)

Code Example (CampaignsPage.tsx):

const handleCreate = async (values: any) => {\n  try {\n    const formData = new FormData();\n    formData.append('title', values.title);\n    formData.append('description', values.description);\n    formData.append('targetGovernmentLevels', JSON.stringify(values.targetGovernmentLevels));\n    formData.append('emailSubjectTemplate', values.emailSubjectTemplate);\n    formData.append('emailBodyTemplate', values.emailBodyTemplate);\n\n    if (values.coverPhoto?.[0]?.originFileObj) {\n      formData.append('coverPhoto', values.coverPhoto[0].originFileObj);\n    }\n\n    await api.post('/campaigns', formData, {\n      headers: { 'Content-Type': 'multipart/form-data' }\n    });\n\n    message.success('Campaign created successfully');\n    fetchCampaigns();\n  } catch (error) {\n    message.error('Failed to create campaign');\n  }\n};\n
"},{"location":"v2/features/influence/campaigns/#2-configure-feature-flags","title":"2. Configure Feature Flags","text":"

[Screenshot: Campaign edit modal with feature flags section]

Steps:

  1. Click Edit on campaign row
  2. Scroll to Feature Flags section
  3. Toggle flags as needed:
  4. allowSmtpEmail: Enable email sending (required for email campaigns)
  5. showResponseWall: Display public response wall
  6. requireEmailVerification: Require email verification for responses
  7. highlightCampaign: Feature on homepage
  8. allowCustomMessage: Let users edit email text before sending
  9. Click Save

Best Practices:

"},{"location":"v2/features/influence/campaigns/#3-test-campaign","title":"3. Test Campaign","text":"

[Screenshot: Campaign preview with test email form]

Steps:

  1. Set campaign status to ACTIVE
  2. Navigate to public campaign page: /campaigns/{slug}
  3. Enter test postal code
  4. Review representative lookup results
  5. Send test email to your own email address
  6. Verify email content and formatting

Troubleshooting:

"},{"location":"v2/features/influence/campaigns/#4-publish-campaign","title":"4. Publish Campaign","text":"

[Screenshot: Campaign status dropdown]

Steps:

  1. Return to Campaigns page
  2. Click Status dropdown on campaign row
  3. Select ACTIVE
  4. Campaign now visible on public campaigns page

Status Lifecycle:

stateDiagram-v2\n    [*] --> DRAFT: Create\n    DRAFT --> ACTIVE: Publish\n    ACTIVE --> PAUSED: Pause\n    PAUSED --> ACTIVE: Resume\n    ACTIVE --> ARCHIVED: Archive\n    PAUSED --> ARCHIVED: Archive\n    ARCHIVED --> [*]
"},{"location":"v2/features/influence/campaigns/#5-monitor-campaign","title":"5. Monitor Campaign","text":"

[Screenshot: Campaign emails drawer with stats]

Steps:

  1. Click View Emails on campaign row
  2. Review email stats:
  3. Total sent
  4. Success rate
  5. Failed emails
  6. View individual email details (recipient, status, sent date)
  7. Retry failed emails if needed

Metrics to Track:

"},{"location":"v2/features/influence/campaigns/#public-workflow","title":"Public Workflow","text":""},{"location":"v2/features/influence/campaigns/#1-browse-campaigns","title":"1. Browse Campaigns","text":"

[Screenshot: Public campaigns list page with featured campaigns]

User Journey:

  1. User visits /campaigns
  2. Sees featured campaigns (if highlightCampaign enabled)
  3. Browses active campaigns grid
  4. Clicks campaign card to view details

Code Example (CampaignsListPage.tsx):

const CampaignsListPage: React.FC = () => {\n  const [campaigns, setCampaigns] = useState<Campaign[]>([]);\n  const [featured, setFeatured] = useState<Campaign[]>([]);\n\n  useEffect(() => {\n    const fetchCampaigns = async () => {\n      const { data } = await axios.get('/api/public/campaigns');\n\n      const featuredCampaigns = data.filter((c: Campaign) =>\n        c.highlightCampaign && c.status === 'ACTIVE'\n      );\n      const regularCampaigns = data.filter((c: Campaign) =>\n        !c.highlightCampaign && c.status === 'ACTIVE'\n      );\n\n      setFeatured(featuredCampaigns);\n      setCampaigns(regularCampaigns);\n    };\n\n    fetchCampaigns();\n  }, []);\n\n  return (\n    <PublicLayout>\n      {featured.length > 0 && (\n        <FeaturedCampaigns campaigns={featured} />\n      )}\n      <CampaignGrid campaigns={campaigns} />\n    </PublicLayout>\n  );\n};\n
"},{"location":"v2/features/influence/campaigns/#2-view-campaign-details","title":"2. View Campaign Details","text":"

[Screenshot: Campaign detail page with postal code lookup form]

User Journey:

  1. User clicks campaign card
  2. Navigated to /campaigns/{slug}
  3. Reads campaign description
  4. Enters postal code in lookup form
  5. System fetches representatives from Represent API
  6. User selects representatives to email
"},{"location":"v2/features/influence/campaigns/#3-send-email","title":"3. Send Email","text":"

[Screenshot: Email form with representative selection]

User Journey:

  1. User reviews list of representatives
  2. Selects representatives to email (checkboxes)
  3. Reviews email subject and body
  4. Edits message if allowCustomMessage enabled
  5. Adds personal details (name, email)
  6. Clicks Send Email
  7. Email jobs added to BullMQ queue
  8. User sees confirmation message

Code Example (CampaignPage.tsx):

const handleSendEmails = async (values: any) => {\n  try {\n    const payload = {\n      campaignId: campaign.id,\n      senderName: values.senderName,\n      senderEmail: values.senderEmail,\n      postalCode: values.postalCode,\n      representativeIds: values.representativeIds,\n      customMessage: campaign.allowCustomMessage ? values.customMessage : null\n    };\n\n    await axios.post('/api/public/campaigns/send-email', payload);\n\n    message.success('Your emails have been sent!');\n\n    if (campaign.showResponseWall) {\n      message.info('Share your response on the Response Wall!');\n    }\n  } catch (error) {\n    message.error('Failed to send emails');\n  }\n};\n
"},{"location":"v2/features/influence/campaigns/#4-submit-response-optional","title":"4. Submit Response (Optional)","text":"

[Screenshot: Response submission form]

User Journey:

  1. After sending email, user clicks Share Your Response
  2. Navigated to /responses/{campaignId}/submit
  3. Fills in response form:
  4. Type (EMAIL, LETTER, PHONE_CALL, etc.)
  5. Message
  6. Screenshot (optional)
  7. Submits response
  8. If requireEmailVerification enabled \u2192 verification email sent
  9. User clicks verification link in email
  10. Response appears on public response wall (after admin approval if moderation enabled)
"},{"location":"v2/features/influence/campaigns/#volunteer-workflow","title":"Volunteer Workflow","text":"

Not applicable \u2014 campaigns are admin-managed and public-facing.

"},{"location":"v2/features/influence/campaigns/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/influence/campaigns/#backend-create-campaign","title":"Backend: Create Campaign","text":"
// api/src/modules/influence/campaigns/campaigns.service.ts\n\nasync createCampaign(\n  data: Prisma.CampaignUncheckedCreateInput,\n  createdByUserId: string\n): Promise<Campaign> {\n  // Generate slug from title\n  const baseSlug = data.title\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, '-')\n    .replace(/^-|-$/g, '');\n\n  let slug = baseSlug;\n  let counter = 1;\n\n  // Ensure unique slug\n  while (await this.prisma.campaign.findUnique({ where: { slug } })) {\n    slug = `${baseSlug}-${counter}`;\n    counter++;\n  }\n\n  return this.prisma.campaign.create({\n    data: {\n      ...data,\n      slug,\n      createdByUserId,\n      status: 'DRAFT',\n      // Default feature flags\n      allowSmtpEmail: data.allowSmtpEmail ?? true,\n      showResponseWall: data.showResponseWall ?? true,\n      requireEmailVerification: data.requireEmailVerification ?? true,\n      allowCustomMessage: data.allowCustomMessage ?? true,\n      showProgressBar: data.showProgressBar ?? true,\n      allowSharing: data.allowSharing ?? true,\n      requirePostalCode: data.requirePostalCode ?? true,\n      notifyOnResponse: data.notifyOnResponse ?? true\n    }\n  });\n}\n
"},{"location":"v2/features/influence/campaigns/#frontend-campaign-card-component","title":"Frontend: Campaign Card Component","text":"
// admin/src/pages/public/CampaignsListPage.tsx\n\nconst CampaignCard: React.FC<{ campaign: Campaign }> = ({ campaign }) => {\n  const navigate = useNavigate();\n\n  return (\n    <Card\n      hoverable\n      cover={\n        campaign.coverPhotoUrl && (\n          <img\n            alt={campaign.title}\n            src={campaign.coverPhotoUrl}\n            style={{ height: 200, objectFit: 'cover' }}\n          />\n        )\n      }\n      onClick={() => navigate(`/campaigns/${campaign.slug}`)}\n    >\n      <Card.Meta\n        title={campaign.title}\n        description={\n          <Space direction=\"vertical\" size=\"small\">\n            <Typography.Paragraph ellipsis={{ rows: 3 }}>\n              {campaign.description}\n            </Typography.Paragraph>\n\n            {campaign.showProgressBar && (\n              <Progress\n                percent={Math.min(\n                  (campaign._count?.responses || 0) / (campaign.responseGoal || 100) * 100,\n                  100\n                )}\n                status=\"active\"\n              />\n            )}\n\n            <Space>\n              {campaign.targetGovernmentLevels.map(level => (\n                <Tag key={level} color=\"blue\">{level}</Tag>\n              ))}\n            </Space>\n          </Space>\n        }\n      />\n    </Card>\n  );\n};\n
"},{"location":"v2/features/influence/campaigns/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/influence/campaigns/#campaign-not-visible-on-public-page","title":"Campaign Not Visible on Public Page","text":"

Symptoms: - Campaign exists in admin but doesn't appear on /campaigns

Solutions:

  1. Check campaign status \u2192 must be ACTIVE
  2. Verify no draft campaigns leaked \u2192 filter by status in query
  3. Check Nginx caching \u2192 clear cache or disable for /api/public/campaigns

Debugging:

# Check campaign status\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_lite -c \\\n  \"SELECT id, title, status, slug FROM campaigns WHERE slug = 'your-slug';\"\n\n# Check public endpoint response\ncurl http://localhost:4000/api/public/campaigns | jq\n
"},{"location":"v2/features/influence/campaigns/#email-template-variables-not-replaced","title":"Email Template Variables Not Replaced","text":"

Symptoms: - Email sent with {{senderName}} instead of actual name

Solutions:

  1. Verify variable syntax \u2192 must use double curly braces {{VAR}}
  2. Check email service interpolation \u2192 ensure processTemplate() called
  3. Verify variable names match \u2192 senderName, senderEmail, postalCode, recipientName, recipientEmail

Code Fix (email.service.ts):

private processTemplate(template: string, variables: Record<string, string>): string {\n  let processed = template;\n\n  Object.entries(variables).forEach(([key, value]) => {\n    const regex = new RegExp(`{{${key}}}`, 'g');\n    processed = processed.replace(regex, value || '');\n  });\n\n  return processed;\n}\n
"},{"location":"v2/features/influence/campaigns/#cover-photo-upload-fails","title":"Cover Photo Upload Fails","text":"

Symptoms: - Upload spinner never completes - Error: \"File too large\"

Solutions:

  1. Check file size \u2192 max 10MB
  2. Verify file format \u2192 must be jpg/jpeg/png/gif/webp
  3. Check upload directory permissions \u2192 /uploads/campaigns must be writable
  4. Increase Nginx upload limit \u2192 client_max_body_size 20M;

Docker Volume Fix:

# docker-compose.yml\nservices:\n  api:\n    volumes:\n      - ./uploads:/app/uploads:rw  # Ensure :rw (read-write)\n
"},{"location":"v2/features/influence/campaigns/#representatives-not-loading","title":"Representatives Not Loading","text":"

Symptoms: - Postal code lookup returns empty array

Solutions:

  1. Check Represent API status \u2192 visit https://represent.opennorth.ca/health
  2. Verify postal code format \u2192 must be valid Canadian postal code (K1A 0A1)
  3. Check representative cache \u2192 may need refresh
  4. Review API rate limits \u2192 Represent API has rate limits

Manual Cache Refresh:

# Via admin UI\n# Navigate to Influence > Representatives\n# Enter postal code in search box\n# Click \"Lookup\"\n\n# Via API\ncurl -X POST http://localhost:4000/api/representatives/lookup \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"postalCode\": \"K1A0A1\"}'\n
"},{"location":"v2/features/influence/campaigns/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/influence/campaigns/#campaign-listing-optimization","title":"Campaign Listing Optimization","text":"

Query Optimization:

// Include response count for progress bar\nconst campaigns = await prisma.campaign.findMany({\n  where: { status: 'ACTIVE' },\n  include: {\n    _count: {\n      select: { responses: true }\n    }\n  },\n  orderBy: [\n    { highlightCampaign: 'desc' }, // Featured first\n    { createdAt: 'desc' }\n  ]\n});\n

Caching Strategy:

"},{"location":"v2/features/influence/campaigns/#email-queue-scaling","title":"Email Queue Scaling","text":"

BullMQ Configuration:

// api/src/services/email-queue.service.ts\n\nconst queue = new Queue('campaign-emails', {\n  connection: redisConnection,\n  defaultJobOptions: {\n    attempts: 3,\n    backoff: {\n      type: 'exponential',\n      delay: 5000 // 5s, 25s, 125s\n    },\n    removeOnComplete: {\n      age: 86400, // Keep completed jobs for 24h\n      count: 1000\n    },\n    removeOnFail: {\n      age: 604800 // Keep failed jobs for 7 days\n    }\n  }\n});\n\n// Worker concurrency\nconst worker = new Worker('campaign-emails', processCampaignEmail, {\n  connection: redisConnection,\n  concurrency: 5 // Process 5 emails simultaneously\n});\n

Monitoring:

"},{"location":"v2/features/influence/campaigns/#cover-photo-optimization","title":"Cover Photo Optimization","text":"

Image Processing:

// api/src/modules/influence/campaigns/campaigns.service.ts\n\nimport sharp from 'sharp';\n\nasync uploadCoverPhoto(file: Express.Multer.File, campaignId: string): Promise<string> {\n  const filename = `${Date.now()}-${file.originalname}`;\n  const uploadPath = `/uploads/campaigns/${campaignId}`;\n\n  // Create directory\n  await fs.mkdir(uploadPath, { recursive: true });\n\n  // Optimize image\n  await sharp(file.buffer)\n    .resize(1200, 630, { // Open Graph ratio\n      fit: 'cover',\n      position: 'center'\n    })\n    .jpeg({ quality: 85 })\n    .toFile(`${uploadPath}/${filename}`);\n\n  return `${uploadPath}/${filename}`;\n}\n

CDN Integration:

"},{"location":"v2/features/influence/campaigns/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/influence/campaigns/#backend-modules","title":"Backend Modules","text":""},{"location":"v2/features/influence/campaigns/#frontend-pages","title":"Frontend Pages","text":""},{"location":"v2/features/influence/campaigns/#database-models_1","title":"Database Models","text":""},{"location":"v2/features/influence/campaigns/#configuration_1","title":"Configuration","text":""},{"location":"v2/features/influence/campaigns/#guides","title":"Guides","text":""},{"location":"v2/features/influence/email-queue/","title":"Email Queue System","text":""},{"location":"v2/features/influence/email-queue/#overview","title":"Overview","text":"

The email queue system manages asynchronous email sending for advocacy campaigns using BullMQ and Redis. It provides reliable email delivery, retry logic, job monitoring, and comprehensive tracking of email campaign effectiveness.

Key Capabilities:

Use Cases:

"},{"location":"v2/features/influence/email-queue/#architecture","title":"Architecture","text":"
graph TD\n    A[Public User] -->|Send Email| B[CampaignPage]\n    B -->|POST /api/public/campaigns/send-email| C[Campaign Service]\n    C -->|Add Job| D[Email Queue Service]\n    D -->|Create Job| E[(BullMQ Redis)]\n\n    F[Email Worker] -->|Poll Jobs| E\n    F -->|Process Job| G{Send Email}\n    G -->|Success| H[Email Service - SMTP]\n    G -->|Failure| I[Retry Logic]\n\n    H -->|Track| J[(CampaignEmail Model)]\n    I -->|Backoff| E\n\n    K[Admin User] -->|Monitor| L[EmailQueuePage]\n    L -->|GET /api/email-queue/stats| D\n    L -->|Pause/Resume| D\n    L -->|Clean Jobs| D\n\n    M[Prometheus] -->|Scrape| N[Metrics Endpoint]\n    N -->|cm_email_queue_size| E\n\n    style E fill:#fff4e1\n    style J fill:#e1f5ff

Flow Description:

  1. User sends email \u2192 Campaign service adds job to BullMQ queue
  2. Worker polls queue \u2192 Picks up job for processing
  3. Email sent via SMTP \u2192 Nodemailer sends email
  4. Success \u2192 Job marked completed, email tracked in database
  5. Failure \u2192 Job retried with exponential backoff (3 attempts)
  6. Admin monitors \u2192 View queue stats, pause/resume, clean old jobs
"},{"location":"v2/features/influence/email-queue/#database-models","title":"Database Models","text":""},{"location":"v2/features/influence/email-queue/#campaignemail-model","title":"CampaignEmail Model","text":"

See CampaignEmail Model Documentation for full schema.

Key Fields:

Field Type Description id String (UUID) Primary key campaignId String Associated campaign recipientEmail String Email recipient recipientName String? Recipient name senderEmail String Sender email address senderName String Sender name subject String Email subject line body String (Text) Email body content status Enum QUEUED, SENT, FAILED jobId String? BullMQ job ID sentAt DateTime? When email was sent failureReason String? Error message if failed

Indexes:

Related Models:

"},{"location":"v2/features/influence/email-queue/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/influence/email-queue/#admin-endpoints","title":"Admin Endpoints","text":"

See Email Queue Module API Reference for full details.

Method Endpoint Auth Description GET /api/email-queue/stats SUPER_ADMIN, INFLUENCE_ADMIN Get queue statistics POST /api/email-queue/pause SUPER_ADMIN, INFLUENCE_ADMIN Pause queue processing POST /api/email-queue/resume SUPER_ADMIN, INFLUENCE_ADMIN Resume queue processing POST /api/email-queue/clean SUPER_ADMIN Clean completed/failed jobs POST /api/email-queue/retry/:jobId SUPER_ADMIN, INFLUENCE_ADMIN Retry failed job"},{"location":"v2/features/influence/email-queue/#public-endpoints","title":"Public Endpoints","text":"

Email queue jobs are created via campaign email endpoints (no direct public access).

"},{"location":"v2/features/influence/email-queue/#configuration","title":"Configuration","text":""},{"location":"v2/features/influence/email-queue/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description REDIS_HOST string localhost Redis hostname REDIS_PORT number 6379 Redis port REDIS_PASSWORD string - Redis password (required) SMTP_HOST string - SMTP server hostname SMTP_PORT number 587 SMTP server port SMTP_USER string - SMTP username SMTP_PASS string - SMTP password SMTP_FROM_EMAIL string - Default sender email SMTP_FROM_NAME string - Default sender name EMAIL_TEST_MODE boolean false Send to MailHog instead of SMTP EMAIL_QUEUE_CONCURRENCY number 5 Max concurrent email workers"},{"location":"v2/features/influence/email-queue/#bullmq-configuration","title":"BullMQ Configuration","text":"
// api/src/services/email-queue.service.ts\n\nconst queueOptions = {\n  connection: {\n    host: process.env.REDIS_HOST,\n    port: parseInt(process.env.REDIS_PORT || '6379'),\n    password: process.env.REDIS_PASSWORD\n  },\n  defaultJobOptions: {\n    attempts: 3,\n    backoff: {\n      type: 'exponential',\n      delay: 5000 // 5s, 25s, 125s\n    },\n    removeOnComplete: {\n      age: 86400, // Keep completed jobs for 24h\n      count: 1000\n    },\n    removeOnFail: {\n      age: 604800 // Keep failed jobs for 7 days\n    }\n  }\n};\n
"},{"location":"v2/features/influence/email-queue/#worker-configuration","title":"Worker Configuration","text":"
const workerOptions = {\n  connection: queueOptions.connection,\n  concurrency: parseInt(process.env.EMAIL_QUEUE_CONCURRENCY || '5'),\n  limiter: {\n    max: 60, // Max 60 emails\n    duration: 60000 // per minute\n  }\n};\n
"},{"location":"v2/features/influence/email-queue/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/influence/email-queue/#1-view-queue-statistics","title":"1. View Queue Statistics","text":"

[Screenshot: EmailQueuePage with queue stats cards]

Steps:

  1. Navigate to Influence > Email Queue
  2. View queue statistics:
  3. Waiting: Jobs queued for processing
  4. Active: Jobs currently being processed
  5. Completed: Successfully sent emails
  6. Failed: Failed emails requiring attention
  7. Monitor queue health (green if waiting < 100)

Code Example (EmailQueuePage.tsx):

const [stats, setStats] = useState({\n  waiting: 0,\n  active: 0,\n  completed: 0,\n  failed: 0,\n  paused: false\n});\n\nuseEffect(() => {\n  const fetchStats = async () => {\n    const { data } = await api.get('/email-queue/stats');\n    setStats(data);\n  };\n\n  fetchStats();\n\n  // Refresh every 5 seconds\n  const interval = setInterval(fetchStats, 5000);\n  return () => clearInterval(interval);\n}, []);\n\nreturn (\n  <Row gutter={16}>\n    <Col span={6}>\n      <Card>\n        <Statistic\n          title=\"Waiting\"\n          value={stats.waiting}\n          valueStyle={{ color: stats.waiting > 100 ? '#cf1322' : '#3f8600' }}\n        />\n      </Card>\n    </Col>\n    <Col span={6}>\n      <Card>\n        <Statistic title=\"Active\" value={stats.active} />\n      </Card>\n    </Col>\n    <Col span={6}>\n      <Card>\n        <Statistic title=\"Completed\" value={stats.completed} />\n      </Card>\n    </Col>\n    <Col span={6}>\n      <Card>\n        <Statistic\n          title=\"Failed\"\n          value={stats.failed}\n          valueStyle={{ color: stats.failed > 0 ? '#cf1322' : undefined }}\n        />\n      </Card>\n    </Col>\n  </Row>\n);\n
"},{"location":"v2/features/influence/email-queue/#2-pauseresume-queue","title":"2. Pause/Resume Queue","text":"

[Screenshot: EmailQueuePage with pause/resume buttons]

Steps:

  1. Click Pause Queue button
  2. Queue stops processing new jobs
  3. Active jobs complete normally
  4. Status indicator shows \"Paused\"
  5. Click Resume Queue to restart processing

Use Cases:

Code Example (email-queue.service.ts):

async pauseQueue(): Promise<void> {\n  await this.queue.pause();\n  logger.info('Email queue paused');\n}\n\nasync resumeQueue(): Promise<void> {\n  await this.queue.resume();\n  logger.info('Email queue resumed');\n}\n\nasync isPaused(): Promise<boolean> {\n  return this.queue.isPaused();\n}\n
"},{"location":"v2/features/influence/email-queue/#3-clean-completed-jobs","title":"3. Clean Completed Jobs","text":"

[Screenshot: EmailQueuePage with clean jobs button]

Steps:

  1. Click Clean Jobs dropdown
  2. Select cleanup type:
  3. Completed (>24h): Remove old successful jobs
  4. Failed (>7d): Remove old failed jobs
  5. All Completed: Remove all successful jobs
  6. Confirm cleanup
  7. Jobs removed from queue, stats updated

Code Example (email-queue.routes.ts):

router.post('/clean', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'), async (req, res) => {\n  try {\n    const { type } = req.body; // 'completed', 'failed', 'all-completed'\n\n    let count = 0;\n\n    if (type === 'completed') {\n      count = await queue.clean(86400000, 1000, 'completed'); // 24h\n    } else if (type === 'failed') {\n      count = await queue.clean(604800000, 1000, 'failed'); // 7d\n    } else if (type === 'all-completed') {\n      count = await queue.clean(0, 0, 'completed'); // All\n    }\n\n    logger.info(`Cleaned ${count} ${type} jobs`);\n\n    res.json({ count });\n  } catch (error) {\n    logger.error('Failed to clean jobs:', error);\n    res.status(500).json({ error: 'Failed to clean jobs' });\n  }\n});\n
"},{"location":"v2/features/influence/email-queue/#4-retry-failed-jobs","title":"4. Retry Failed Jobs","text":"

[Screenshot: Failed jobs table with retry buttons]

Steps:

  1. Scroll to Failed Jobs section
  2. View failed job details (error message, recipient)
  3. Click Retry button on specific job
  4. Job re-queued for processing
  5. Monitor in Active tab

Bulk Retry:

  1. Select multiple failed jobs (checkboxes)
  2. Click Retry Selected button
  3. All selected jobs re-queued

Code Example (email-queue.service.ts):

async retryFailedJob(jobId: string): Promise<void> {\n  const job = await this.queue.getJob(jobId);\n\n  if (!job) {\n    throw new Error('Job not found');\n  }\n\n  if (await job.isFailed()) {\n    await job.retry();\n    logger.info(`Retrying job ${jobId}`);\n  } else {\n    throw new Error('Job is not failed');\n  }\n}\n\nasync retryAllFailed(): Promise<number> {\n  const failed = await this.queue.getFailed();\n  let count = 0;\n\n  for (const job of failed) {\n    await job.retry();\n    count++;\n  }\n\n  logger.info(`Retried ${count} failed jobs`);\n  return count;\n}\n
"},{"location":"v2/features/influence/email-queue/#public-workflow","title":"Public Workflow","text":""},{"location":"v2/features/influence/email-queue/#1-send-campaign-email","title":"1. Send Campaign Email","text":"

[Screenshot: CampaignPage with email sending form]

User Journey:

  1. User selects representatives to email
  2. Fills in sender details (name, email)
  3. Reviews/edits email content (if allowed)
  4. Clicks Send Email button
  5. System creates email jobs (one per recipient)
  6. Jobs added to BullMQ queue
  7. User sees confirmation message

Code Example (campaigns-public.routes.ts):

router.post('/send-email', async (req, res) => {\n  try {\n    const {\n      campaignId,\n      senderName,\n      senderEmail,\n      postalCode,\n      representativeIds,\n      customMessage\n    } = req.body;\n\n    const campaign = await prisma.campaign.findUnique({\n      where: { id: campaignId }\n    });\n\n    if (!campaign || campaign.status !== 'ACTIVE') {\n      return res.status(400).json({ error: 'Campaign not active' });\n    }\n\n    const representatives = await prisma.representative.findMany({\n      where: { id: { in: representativeIds } }\n    });\n\n    // Create email jobs\n    const emailJobs = [];\n\n    for (const rep of representatives) {\n      const emailData = {\n        campaignId,\n        recipientEmail: rep.email,\n        recipientName: rep.name,\n        senderEmail,\n        senderName,\n        subject: processTemplate(campaign.emailSubjectTemplate, {\n          senderName,\n          recipientName: rep.name,\n          postalCode\n        }),\n        body: customMessage || processTemplate(campaign.emailBodyTemplate, {\n          senderName,\n          senderEmail,\n          recipientName: rep.name,\n          recipientEmail: rep.email,\n          postalCode\n        })\n      };\n\n      // Add to queue\n      const job = await emailQueueService.addEmail(emailData);\n\n      emailJobs.push(job);\n    }\n\n    res.json({\n      success: true,\n      emailsQueued: emailJobs.length\n    });\n  } catch (error) {\n    logger.error('Failed to queue campaign emails:', error);\n    res.status(500).json({ error: 'Failed to send emails' });\n  }\n});\n
"},{"location":"v2/features/influence/email-queue/#2-job-processing","title":"2. Job Processing","text":"

Worker Processing Logic:

// api/src/services/email-queue.service.ts\n\nimport { Worker } from 'bullmq';\nimport { emailService } from './email.service';\n\nconst worker = new Worker('campaign-emails', async (job) => {\n  const {\n    campaignId,\n    recipientEmail,\n    recipientName,\n    senderEmail,\n    senderName,\n    subject,\n    body\n  } = job.data;\n\n  try {\n    // Send email via nodemailer\n    await emailService.send({\n      to: recipientEmail,\n      from: {\n        email: process.env.SMTP_FROM_EMAIL!,\n        name: process.env.SMTP_FROM_NAME!\n      },\n      replyTo: {\n        email: senderEmail,\n        name: senderName\n      },\n      subject,\n      html: body\n    });\n\n    // Update database record\n    await prisma.campaignEmail.update({\n      where: { jobId: job.id },\n      data: {\n        status: 'SENT',\n        sentAt: new Date()\n      }\n    });\n\n    logger.info(`Sent campaign email ${job.id} to ${recipientEmail}`);\n\n    // Update Prometheus metric\n    metrics.campaignEmailsSent.inc({ campaign_id: campaignId });\n\n    return { success: true };\n  } catch (error) {\n    logger.error(`Failed to send email ${job.id}:`, error);\n\n    // Update database record\n    await prisma.campaignEmail.update({\n      where: { jobId: job.id },\n      data: {\n        status: 'FAILED',\n        failureReason: error.message\n      }\n    });\n\n    throw error; // Let BullMQ handle retry\n  }\n}, workerOptions);\n\nworker.on('completed', (job) => {\n  logger.info(`Job ${job.id} completed`);\n});\n\nworker.on('failed', (job, err) => {\n  logger.error(`Job ${job?.id} failed:`, err);\n});\n
"},{"location":"v2/features/influence/email-queue/#volunteer-workflow","title":"Volunteer Workflow","text":"

Not applicable \u2014 email queue is system-level.

"},{"location":"v2/features/influence/email-queue/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/influence/email-queue/#backend-email-queue-service","title":"Backend: Email Queue Service","text":"
// api/src/services/email-queue.service.ts\n\nimport { Queue, QueueEvents } from 'bullmq';\nimport { logger } from '../utils/logger';\nimport { prisma } from '../config/database';\n\nexport class EmailQueueService {\n  private queue: Queue;\n  private queueEvents: QueueEvents;\n\n  constructor() {\n    const connection = {\n      host: process.env.REDIS_HOST!,\n      port: parseInt(process.env.REDIS_PORT || '6379'),\n      password: process.env.REDIS_PASSWORD\n    };\n\n    this.queue = new Queue('campaign-emails', {\n      connection,\n      defaultJobOptions: {\n        attempts: 3,\n        backoff: {\n          type: 'exponential',\n          delay: 5000\n        },\n        removeOnComplete: {\n          age: 86400,\n          count: 1000\n        },\n        removeOnFail: {\n          age: 604800\n        }\n      }\n    });\n\n    this.queueEvents = new QueueEvents('campaign-emails', { connection });\n\n    this.setupEventHandlers();\n  }\n\n  private setupEventHandlers(): void {\n    this.queueEvents.on('completed', ({ jobId }) => {\n      logger.info(`Email job ${jobId} completed`);\n    });\n\n    this.queueEvents.on('failed', ({ jobId, failedReason }) => {\n      logger.error(`Email job ${jobId} failed: ${failedReason}`);\n    });\n  }\n\n  async addEmail(data: any): Promise<{ jobId: string }> {\n    // Create database record\n    const emailRecord = await prisma.campaignEmail.create({\n      data: {\n        ...data,\n        status: 'QUEUED'\n      }\n    });\n\n    // Add job to queue\n    const job = await this.queue.add('send-email', data, {\n      jobId: emailRecord.id\n    });\n\n    // Update database with job ID\n    await prisma.campaignEmail.update({\n      where: { id: emailRecord.id },\n      data: { jobId: job.id }\n    });\n\n    logger.info(`Queued email job ${job.id}`);\n\n    return { jobId: job.id! };\n  }\n\n  async getStats(): Promise<any> {\n    const counts = await this.queue.getJobCounts();\n\n    return {\n      waiting: counts.waiting || 0,\n      active: counts.active || 0,\n      completed: counts.completed || 0,\n      failed: counts.failed || 0,\n      paused: await this.queue.isPaused()\n    };\n  }\n\n  async pauseQueue(): Promise<void> {\n    await this.queue.pause();\n  }\n\n  async resumeQueue(): Promise<void> {\n    await this.queue.resume();\n  }\n\n  async clean(grace: number, limit: number, type: string): Promise<number> {\n    return this.queue.clean(grace, limit, type as any);\n  }\n}\n\nexport const emailQueueService = new EmailQueueService();\n
"},{"location":"v2/features/influence/email-queue/#frontend-queue-stats-dashboard","title":"Frontend: Queue Stats Dashboard","text":"
// admin/src/pages/EmailQueuePage.tsx\n\nimport React, { useState, useEffect } from 'react';\nimport { Card, Row, Col, Statistic, Button, Space, message } from 'antd';\nimport { PlayCircleOutlined, PauseCircleOutlined, ClearOutlined } from '@ant-design/icons';\nimport { api } from '../../lib/api';\n\nconst EmailQueuePage: React.FC = () => {\n  const [stats, setStats] = useState<any>(null);\n  const [loading, setLoading] = useState(false);\n\n  const fetchStats = async () => {\n    const { data } = await api.get('/email-queue/stats');\n    setStats(data);\n  };\n\n  useEffect(() => {\n    fetchStats();\n    const interval = setInterval(fetchStats, 5000);\n    return () => clearInterval(interval);\n  }, []);\n\n  const handlePause = async () => {\n    setLoading(true);\n    try {\n      await api.post('/email-queue/pause');\n      message.success('Queue paused');\n      fetchStats();\n    } catch (error) {\n      message.error('Failed to pause queue');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const handleResume = async () => {\n    setLoading(true);\n    try {\n      await api.post('/email-queue/resume');\n      message.success('Queue resumed');\n      fetchStats();\n    } catch (error) {\n      message.error('Failed to resume queue');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const handleClean = async (type: string) => {\n    setLoading(true);\n    try {\n      const { data } = await api.post('/email-queue/clean', { type });\n      message.success(`Cleaned ${data.count} jobs`);\n      fetchStats();\n    } catch (error) {\n      message.error('Failed to clean jobs');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  if (!stats) return <Card loading />;\n\n  return (\n    <Space direction=\"vertical\" size=\"large\" style={{ width: '100%' }}>\n      <Card title=\"Queue Statistics\">\n        <Row gutter={16}>\n          <Col span={6}>\n            <Statistic\n              title=\"Waiting\"\n              value={stats.waiting}\n              valueStyle={{ color: stats.waiting > 100 ? '#cf1322' : '#3f8600' }}\n            />\n          </Col>\n          <Col span={6}>\n            <Statistic title=\"Active\" value={stats.active} />\n          </Col>\n          <Col span={6}>\n            <Statistic title=\"Completed\" value={stats.completed} />\n          </Col>\n          <Col span={6}>\n            <Statistic\n              title=\"Failed\"\n              value={stats.failed}\n              valueStyle={{ color: stats.failed > 0 ? '#cf1322' : undefined }}\n            />\n          </Col>\n        </Row>\n      </Card>\n\n      <Card title=\"Queue Controls\">\n        <Space>\n          {stats.paused ? (\n            <Button\n              type=\"primary\"\n              icon={<PlayCircleOutlined />}\n              onClick={handleResume}\n              loading={loading}\n            >\n              Resume Queue\n            </Button>\n          ) : (\n            <Button\n              icon={<PauseCircleOutlined />}\n              onClick={handlePause}\n              loading={loading}\n            >\n              Pause Queue\n            </Button>\n          )}\n\n          <Button\n            icon={<ClearOutlined />}\n            onClick={() => handleClean('completed')}\n            loading={loading}\n          >\n            Clean Completed\n          </Button>\n\n          <Button\n            danger\n            icon={<ClearOutlined />}\n            onClick={() => handleClean('failed')}\n            loading={loading}\n          >\n            Clean Failed\n          </Button>\n        </Space>\n      </Card>\n    </Space>\n  );\n};\n\nexport default EmailQueuePage;\n
"},{"location":"v2/features/influence/email-queue/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/influence/email-queue/#emails-stuck-in-queue","title":"Emails Stuck in Queue","text":"

Symptoms: - Waiting count increases but active/completed don't - Jobs not processing

Solutions:

  1. Check worker status \u2192 docker compose logs api | grep \"Worker\"
  2. Verify Redis connection \u2192 docker compose exec redis redis-cli ping
  3. Check SMTP configuration \u2192 test with /api/auth/test-email
  4. Restart worker \u2192 docker compose restart api

Debugging:

# Check Redis keys\ndocker compose exec redis redis-cli --pass $REDIS_PASSWORD\n> KEYS bull:campaign-emails:*\n\n# Check worker logs\ndocker compose logs -f api | grep \"Email worker\"\n\n# Check queue status\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:4000/api/email-queue/stats\n
"},{"location":"v2/features/influence/email-queue/#high-failure-rate","title":"High Failure Rate","text":"

Symptoms: - Many jobs failing - Failed count increasing rapidly

Solutions:

  1. Check SMTP credentials \u2192 verify username/password
  2. Review failure reasons \u2192 check failureReason field in database
  3. Check SMTP server status \u2192 verify server is reachable
  4. Review rate limits \u2192 may be hitting SMTP server limits

Common Failure Reasons:

Code Fix (email.service.ts):

// Add better error handling\nasync send(options: EmailOptions): Promise<void> {\n  try {\n    await this.transporter.sendMail(options);\n  } catch (error) {\n    if (error.responseCode === 535) {\n      throw new Error('SMTP authentication failed - check credentials');\n    } else if (error.responseCode === 550) {\n      throw new Error('Recipient mailbox unavailable');\n    } else if (error.code === 'ETIMEDOUT') {\n      throw new Error('SMTP server connection timeout');\n    } else {\n      throw error;\n    }\n  }\n}\n
"},{"location":"v2/features/influence/email-queue/#redis-connection-issues","title":"Redis Connection Issues","text":"

Symptoms: - Error: \"ECONNREFUSED\" or \"NOAUTH\" - Queue operations fail

Solutions:

  1. Verify Redis is running \u2192 docker compose ps redis
  2. Check Redis password \u2192 ensure REDIS_PASSWORD matches docker-compose.yml
  3. Check Redis port \u2192 default 6379
  4. Verify Redis auth \u2192 docker compose exec redis redis-cli --pass $REDIS_PASSWORD ping

Fix Redis Auth:

# docker-compose.yml\nservices:\n  redis:\n    image: redis:7-alpine\n    command: redis-server --requirepass ${REDIS_PASSWORD}\n    ports:\n      - \"6379:6379\"\n
"},{"location":"v2/features/influence/email-queue/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/influence/email-queue/#concurrency-tuning","title":"Concurrency Tuning","text":"

Worker Concurrency:

// Adjust based on SMTP server limits\nconst workerOptions = {\n  concurrency: 5, // Process 5 emails simultaneously\n  limiter: {\n    max: 60, // Max 60 emails per minute\n    duration: 60000\n  }\n};\n

SMTP Server Limits:

"},{"location":"v2/features/influence/email-queue/#queue-monitoring","title":"Queue Monitoring","text":"

Prometheus Metrics:

import { Counter, Gauge } from 'prom-client';\n\nexport const campaignEmailsQueued = new Counter({\n  name: 'cm_campaign_emails_queued_total',\n  help: 'Total campaign emails queued',\n  labelNames: ['campaign_id']\n});\n\nexport const campaignEmailsSent = new Counter({\n  name: 'cm_campaign_emails_sent_total',\n  help: 'Total campaign emails sent',\n  labelNames: ['campaign_id']\n});\n\nexport const emailQueueSize = new Gauge({\n  name: 'cm_email_queue_size',\n  help: 'Current email queue size',\n  labelNames: ['status']\n});\n\n// Update gauge every 30 seconds\nsetInterval(async () => {\n  const stats = await emailQueueService.getStats();\n\n  emailQueueSize.set({ status: 'waiting' }, stats.waiting);\n  emailQueueSize.set({ status: 'active' }, stats.active);\n  emailQueueSize.set({ status: 'failed' }, stats.failed);\n}, 30000);\n
"},{"location":"v2/features/influence/email-queue/#database-optimization","title":"Database Optimization","text":"

Index Strategy:

CREATE INDEX idx_campaign_email_status ON campaign_emails (status);\nCREATE INDEX idx_campaign_email_campaign_id ON campaign_emails (campaign_id);\nCREATE INDEX idx_campaign_email_sent_at ON campaign_emails (sent_at);\n

Query Optimization:

// Paginated campaign email stats\nconst emails = await prisma.campaignEmail.findMany({\n  where: { campaignId },\n  select: {\n    id: true,\n    recipientEmail: true,\n    status: true,\n    sentAt: true,\n    failureReason: true\n  },\n  orderBy: { createdAt: 'desc' },\n  take: 100,\n  skip: page * 100\n});\n
"},{"location":"v2/features/influence/email-queue/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/influence/email-queue/#backend-modules","title":"Backend Modules","text":""},{"location":"v2/features/influence/email-queue/#frontend-pages","title":"Frontend Pages","text":""},{"location":"v2/features/influence/email-queue/#database-models_1","title":"Database Models","text":""},{"location":"v2/features/influence/email-queue/#configuration_1","title":"Configuration","text":""},{"location":"v2/features/influence/email-queue/#monitoring","title":"Monitoring","text":""},{"location":"v2/features/influence/postal-codes/","title":"Postal Code Geocoding Cache","text":""},{"location":"v2/features/influence/postal-codes/#overview","title":"Overview","text":"

The postal code geocoding cache system stores geographic coordinates for Canadian postal codes, enabling faster representative lookups and reducing external API calls. It integrates with the multi-provider geocoding service to provide reliable centroid calculations for postal code-based geographic queries.

Key Capabilities:

Use Cases:

"},{"location":"v2/features/influence/postal-codes/#architecture","title":"Architecture","text":"
graph TD\n    A[Campaign Service] -->|Lookup Postal Code| B[Postal Code Service]\n    B -->|Check Cache| C{Cache Hit?}\n    C -->|Yes| D[Return Cached Centroid]\n    C -->|No| E[Geocoding Service]\n\n    E -->|Geocode| F[Multi-Provider Geocoding]\n    F -->|Parse Result| G[Extract Centroid]\n    G -->|Save| H[(PostalCodeCache Model)]\n    H -->|Return| D\n\n    I[Admin] -->|View Stats| J[RepresentativesPage]\n    J -->|Display| K[Cache Statistics]\n\n    style H fill:#e1f5ff\n    style F fill:#fff4e1
"},{"location":"v2/features/influence/postal-codes/#database-models","title":"Database Models","text":""},{"location":"v2/features/influence/postal-codes/#postalcodecache-model","title":"PostalCodeCache Model","text":"

See PostalCodeCache Model Documentation for full schema.

Key Fields:

Field Type Description postalCode String Normalized postal code (primary key) latitude Float Centroid latitude longitude Float Centroid longitude city String? City name province String? Province abbreviation

Indexes:

Related Models:

"},{"location":"v2/features/influence/postal-codes/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/influence/postal-codes/#admin-endpoints","title":"Admin Endpoints","text":"Method Endpoint Auth Description GET /api/postal-codes/stats SUPER_ADMIN, INFLUENCE_ADMIN Get cache statistics POST /api/postal-codes/lookup SUPER_ADMIN, INFLUENCE_ADMIN Manual postal code lookup"},{"location":"v2/features/influence/postal-codes/#public-endpoints","title":"Public Endpoints","text":"

Postal code lookups are performed automatically via representative lookup (no direct public access).

"},{"location":"v2/features/influence/postal-codes/#configuration","title":"Configuration","text":""},{"location":"v2/features/influence/postal-codes/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description GEOCODING_PROVIDER string nominatim Default geocoding provider GEOCODING_FALLBACK_PROVIDERS string - Comma-separated fallback providers"},{"location":"v2/features/influence/postal-codes/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/influence/postal-codes/#1-view-cache-statistics","title":"1. View Cache Statistics","text":"

Steps:

  1. Navigate to Influence > Representatives
  2. View postal code cache statistics
  3. Monitor cache hit rate
"},{"location":"v2/features/influence/postal-codes/#public-workflow","title":"Public Workflow","text":"

Postal code caching is automatic and transparent to public users.

"},{"location":"v2/features/influence/postal-codes/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/influence/postal-codes/#backend-postal-code-caching","title":"Backend: Postal Code Caching","text":"
// api/src/modules/influence/postal-codes/postal-codes.service.ts\n\nexport class PostalCodeService {\n  async getOrCreateCache(postalCode: string): Promise<PostalCodeCache> {\n    const normalized = postalCode.toUpperCase().replace(/\\s/g, '');\n\n    // Check cache\n    const cached = await prisma.postalCodeCache.findUnique({\n      where: { postalCode: normalized }\n    });\n\n    if (cached) {\n      return cached;\n    }\n\n    // Geocode postal code\n    const result = await geocodingService.geocode({\n      query: postalCode,\n      country: 'CA'\n    });\n\n    if (!result) {\n      throw new Error('Failed to geocode postal code');\n    }\n\n    // Create cache entry\n    return prisma.postalCodeCache.create({\n      data: {\n        postalCode: normalized,\n        latitude: result.latitude,\n        longitude: result.longitude,\n        city: result.city,\n        province: result.province\n      }\n    });\n  }\n}\n
"},{"location":"v2/features/influence/postal-codes/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/influence/representatives/","title":"Representative Lookup System","text":""},{"location":"v2/features/influence/representatives/#overview","title":"Overview","text":"

The representative lookup system integrates with the Represent API (Open North) to provide real-time postal code-based representative lookups for advocacy campaigns. It includes intelligent caching to minimize API calls, support for all Canadian government levels, and admin tools for cache management.

Key Capabilities:

Use Cases:

"},{"location":"v2/features/influence/representatives/#architecture","title":"Architecture","text":"
graph TD\n    A[Public User] -->|Enter Postal Code| B[CampaignPage]\n    B -->|POST /api/public/representatives/lookup| C[Representative Service]\n\n    C -->|Check Cache| D{Cache Hit?}\n    D -->|Yes| E[Return Cached Reps]\n    D -->|No| F[Represent API Client]\n\n    F -->|GET /postcodes/:code| G[Represent API]\n    G -->|Return Reps| F\n    F -->|Parse & Save| H[(Representative Model)]\n    H -->|Return| E\n\n    I[Admin User] -->|View Cache| J[RepresentativesPage]\n    J -->|GET /api/representatives| C\n    J -->|Manual Lookup| C\n    J -->|Clear Cache| K[Delete Service]\n    K -->|Delete| H\n\n    L[Cache Invalidation Job] -->|Check lastUpdated| H\n    L -->|Delete Stale| H\n\n    style H fill:#e1f5ff\n    style G fill:#fff4e1

Flow Description:

  1. User enters postal code \u2192 Representative service checks cache
  2. Cache miss \u2192 Represent API client fetches representatives
  3. API response \u2192 Parse representatives, save to cache
  4. Cache hit \u2192 Return cached representatives (skip API call)
  5. Admin management \u2192 View cache stats, manual lookup, clear cache
  6. Cache invalidation \u2192 Automatic cleanup of stale entries (>30 days)
"},{"location":"v2/features/influence/representatives/#database-models","title":"Database Models","text":""},{"location":"v2/features/influence/representatives/#representative-model","title":"Representative Model","text":"

See Representative Model Documentation for full schema.

Key Fields:

Field Type Description id String (UUID) Primary key representId String Represent API unique identifier name String Full name of representative email String Email address districtName String Electoral district name electedOffice String Office held (MP, MPP, Mayor, etc.) partyName String? Political party affiliation photoUrl String? Profile photo URL postalCode String Associated postal code (cache key) level String Government level (federal, provincial, municipal) lastUpdated DateTime Cache timestamp

Indexes:

Related Models:

"},{"location":"v2/features/influence/representatives/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/influence/representatives/#admin-endpoints","title":"Admin Endpoints","text":"

See Representatives Module API Reference for full details.

Method Endpoint Auth Description GET /api/representatives SUPER_ADMIN, INFLUENCE_ADMIN List all cached representatives GET /api/representatives/stats SUPER_ADMIN, INFLUENCE_ADMIN Get cache statistics POST /api/representatives/lookup SUPER_ADMIN, INFLUENCE_ADMIN Manual postal code lookup DELETE /api/representatives/:id SUPER_ADMIN, INFLUENCE_ADMIN Delete cached representative DELETE /api/representatives/postal-code/:postalCode SUPER_ADMIN, INFLUENCE_ADMIN Delete all reps for postal code"},{"location":"v2/features/influence/representatives/#public-endpoints","title":"Public Endpoints","text":"

See Representatives Module API Reference.

Method Endpoint Auth Description POST /api/public/representatives/lookup None Lookup representatives by postal code"},{"location":"v2/features/influence/representatives/#configuration","title":"Configuration","text":""},{"location":"v2/features/influence/representatives/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description REPRESENT_API_URL string https://represent.opennorth.ca Represent API base URL REPRESENT_CACHE_TTL number 2592000 Cache TTL in seconds (30 days) REPRESENT_RATE_LIMIT number 60 Max requests per minute"},{"location":"v2/features/influence/representatives/#represent-api","title":"Represent API","text":"

The Represent API is a public service provided by Open North. No API key required.

API Documentation: https://represent.opennorth.ca/api/

Endpoints Used:

Rate Limits:

Postal Code Format:

"},{"location":"v2/features/influence/representatives/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/influence/representatives/#1-view-cache-statistics","title":"1. View Cache Statistics","text":"

[Screenshot: RepresentativesPage with cache stats cards]

Steps:

  1. Navigate to Influence > Representatives
  2. View cache statistics:
  3. Total Cached: Total representatives in cache
  4. Unique Postal Codes: Number of postal codes cached
  5. Cache Hit Rate: Percentage of lookups served from cache
  6. Stale Entries: Entries older than 30 days

Code Example (RepresentativesPage.tsx):

const [stats, setStats] = useState({\n  totalCached: 0,\n  uniquePostalCodes: 0,\n  cacheHitRate: 0,\n  staleEntries: 0\n});\n\nuseEffect(() => {\n  const fetchStats = async () => {\n    const { data } = await api.get('/representatives/stats');\n    setStats(data);\n  };\n\n  fetchStats();\n}, []);\n\nreturn (\n  <Row gutter={16}>\n    <Col span={6}>\n      <Card>\n        <Statistic title=\"Total Cached\" value={stats.totalCached} />\n      </Card>\n    </Col>\n    <Col span={6}>\n      <Card>\n        <Statistic title=\"Unique Postal Codes\" value={stats.uniquePostalCodes} />\n      </Card>\n    </Col>\n    <Col span={6}>\n      <Card>\n        <Statistic\n          title=\"Cache Hit Rate\"\n          value={stats.cacheHitRate}\n          suffix=\"%\"\n          precision={1}\n        />\n      </Card>\n    </Col>\n    <Col span={6}>\n      <Card>\n        <Statistic\n          title=\"Stale Entries\"\n          value={stats.staleEntries}\n          valueStyle={{ color: stats.staleEntries > 0 ? '#cf1322' : undefined }}\n        />\n      </Card>\n    </Col>\n  </Row>\n);\n
"},{"location":"v2/features/influence/representatives/#2-manual-postal-code-lookup","title":"2. Manual Postal Code Lookup","text":"

[Screenshot: RepresentativesPage with postal code search form]

Steps:

  1. Enter postal code in search box (e.g., \"K1A 0A1\")
  2. Click Lookup button
  3. View results:
  4. Representative name, office, party
  5. Electoral district
  6. Email address (if available)
  7. Results automatically cached for future lookups

Use Cases:

Code Example (representatives.service.ts):

async lookupByPostalCode(postalCode: string): Promise<Representative[]> {\n  // Normalize postal code\n  const normalized = postalCode.toUpperCase().replace(/\\s/g, '');\n\n  // Check cache first (within last 30 days)\n  const cached = await this.prisma.representative.findMany({\n    where: {\n      postalCode: normalized,\n      lastUpdated: {\n        gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) // 30 days\n      }\n    }\n  });\n\n  if (cached.length > 0) {\n    logger.info(`Cache hit for postal code ${normalized}`);\n    return cached;\n  }\n\n  // Cache miss - fetch from Represent API\n  logger.info(`Cache miss for postal code ${normalized}, fetching from API`);\n\n  const representatives = await this.representApiClient.getRepresentativesByPostalCode(\n    normalized\n  );\n\n  // Save to cache\n  const saved = await Promise.all(\n    representatives.map(rep =>\n      this.prisma.representative.upsert({\n        where: { representId: rep.representId },\n        update: {\n          ...rep,\n          postalCode: normalized,\n          lastUpdated: new Date()\n        },\n        create: {\n          ...rep,\n          postalCode: normalized,\n          lastUpdated: new Date()\n        }\n      })\n    )\n  );\n\n  return saved;\n}\n
"},{"location":"v2/features/influence/representatives/#3-clear-stale-cache-entries","title":"3. Clear Stale Cache Entries","text":"

[Screenshot: RepresentativesPage with \"Clear Stale Cache\" button]

Steps:

  1. Click Clear Stale Cache button
  2. Confirm deletion in modal
  3. System deletes all entries older than 30 days
  4. View updated cache statistics

Automatic Cleanup:

Cache invalidation also runs automatically via cron job (daily at 2 AM):

// api/src/server.ts\n\nimport cron from 'node-cron';\n\n// Clean stale representative cache daily at 2 AM\ncron.schedule('0 2 * * *', async () => {\n  try {\n    const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);\n\n    const result = await prisma.representative.deleteMany({\n      where: {\n        lastUpdated: {\n          lt: thirtyDaysAgo\n        }\n      }\n    });\n\n    logger.info(`Deleted ${result.count} stale representative cache entries`);\n  } catch (error) {\n    logger.error('Failed to clean representative cache:', error);\n  }\n});\n
"},{"location":"v2/features/influence/representatives/#4-delete-specific-cache-entries","title":"4. Delete Specific Cache Entries","text":"

[Screenshot: RepresentativesPage table with delete buttons]

Steps:

  1. Browse cached representatives table
  2. Click Delete button on specific row
  3. Confirm deletion
  4. Representative removed from cache (will be re-fetched on next lookup)

Bulk Delete by Postal Code:

  1. Click Delete All button on postal code group
  2. Confirm deletion
  3. All representatives for that postal code removed from cache
"},{"location":"v2/features/influence/representatives/#public-workflow","title":"Public Workflow","text":""},{"location":"v2/features/influence/representatives/#1-enter-postal-code","title":"1. Enter Postal Code","text":"

[Screenshot: CampaignPage with postal code input field]

User Journey:

  1. User visits campaign page (/campaigns/{slug})
  2. Enters postal code in lookup form
  3. Clicks Find My Representatives
  4. System performs lookup (cache or API)
  5. Representatives displayed below form

Code Example (CampaignPage.tsx):

const [representatives, setRepresentatives] = useState<Representative[]>([]);\nconst [loading, setLoading] = useState(false);\n\nconst handleLookup = async (values: { postalCode: string }) => {\n  setLoading(true);\n\n  try {\n    const { data } = await axios.post('/api/public/representatives/lookup', {\n      postalCode: values.postalCode\n    });\n\n    setRepresentatives(data);\n\n    if (data.length === 0) {\n      message.warning('No representatives found for this postal code');\n    }\n  } catch (error) {\n    message.error('Failed to lookup representatives');\n  } finally {\n    setLoading(false);\n  }\n};\n\nreturn (\n  <Form onFinish={handleLookup}>\n    <Form.Item\n      name=\"postalCode\"\n      label=\"Postal Code\"\n      rules={[\n        { required: true, message: 'Please enter your postal code' },\n        {\n          pattern: /^[A-Za-z]\\d[A-Za-z][ -]?\\d[A-Za-z]\\d$/,\n          message: 'Please enter a valid Canadian postal code'\n        }\n      ]}\n    >\n      <Input placeholder=\"K1A 0A1\" maxLength={7} />\n    </Form.Item>\n\n    <Form.Item>\n      <Button type=\"primary\" htmlType=\"submit\" loading={loading}>\n        Find My Representatives\n      </Button>\n    </Form.Item>\n  </Form>\n);\n
"},{"location":"v2/features/influence/representatives/#2-view-representatives","title":"2. View Representatives","text":"

[Screenshot: Representative cards with contact information]

Display Fields:

Filtering:

Representatives filtered by campaign's targetGovernmentLevels:

// Filter representatives by campaign levels\nconst filteredRepresentatives = representatives.filter(rep =>\n  campaign.targetGovernmentLevels.includes(rep.level)\n);\n
"},{"location":"v2/features/influence/representatives/#3-select-representatives-to-email","title":"3. Select Representatives to Email","text":"

[Screenshot: Representative list with checkboxes]

User Journey:

  1. User reviews list of representatives
  2. Selects representatives to email (checkboxes)
  3. Clicks Continue to email form
  4. System pre-populates recipient list

Code Example:

const [selectedReps, setSelectedReps] = useState<string[]>([]);\n\nconst handleSelectAll = () => {\n  setSelectedReps(representatives.map(r => r.id));\n};\n\nconst handleSelectNone = () => {\n  setSelectedReps([]);\n};\n\nreturn (\n  <Space direction=\"vertical\" style={{ width: '100%' }}>\n    <Space>\n      <Button onClick={handleSelectAll}>Select All</Button>\n      <Button onClick={handleSelectNone}>Select None</Button>\n    </Space>\n\n    <Checkbox.Group\n      value={selectedReps}\n      onChange={setSelectedReps}\n      style={{ width: '100%' }}\n    >\n      {representatives.map(rep => (\n        <Card key={rep.id} style={{ marginBottom: 16 }}>\n          <Checkbox value={rep.id}>\n            <Space>\n              {rep.photoUrl && (\n                <Avatar src={rep.photoUrl} size={64} />\n              )}\n              <Space direction=\"vertical\" size={0}>\n                <Typography.Text strong>{rep.name}</Typography.Text>\n                <Typography.Text type=\"secondary\">{rep.electedOffice}</Typography.Text>\n                <Typography.Text type=\"secondary\">{rep.districtName}</Typography.Text>\n                {rep.partyName && <Tag>{rep.partyName}</Tag>}\n              </Space>\n            </Space>\n          </Checkbox>\n        </Card>\n      ))}\n    </Checkbox.Group>\n  </Space>\n);\n
"},{"location":"v2/features/influence/representatives/#volunteer-workflow","title":"Volunteer Workflow","text":"

Not applicable \u2014 representative lookup is public-facing and admin-managed.

"},{"location":"v2/features/influence/representatives/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/influence/representatives/#backend-represent-api-client","title":"Backend: Represent API Client","text":"
// api/src/modules/influence/representatives/represent-api.client.ts\n\nimport axios from 'axios';\nimport { logger } from '../../../utils/logger';\n\nconst REPRESENT_API_URL = process.env.REPRESENT_API_URL || 'https://represent.opennorth.ca';\n\ninterface RepresentApiResponse {\n  objects: Array<{\n    name: string;\n    email: string;\n    district_name: string;\n    elected_office: string;\n    party_name?: string;\n    photo_url?: string;\n    url: string;\n    representative_set_name: string;\n  }>;\n}\n\nexport class RepresentApiClient {\n  async getRepresentativesByPostalCode(postalCode: string): Promise<any[]> {\n    try {\n      const { data } = await axios.get<RepresentApiResponse>(\n        `${REPRESENT_API_URL}/postcodes/${postalCode}/`,\n        {\n          headers: {\n            'Accept': 'application/json'\n          },\n          timeout: 10000\n        }\n      );\n\n      return data.objects.map(rep => ({\n        representId: this.extractRepresentId(rep.url),\n        name: rep.name,\n        email: rep.email || null,\n        districtName: rep.district_name,\n        electedOffice: rep.elected_office,\n        partyName: rep.party_name || null,\n        photoUrl: rep.photo_url || null,\n        level: this.mapGovernmentLevel(rep.representative_set_name)\n      }));\n    } catch (error) {\n      if (axios.isAxiosError(error)) {\n        if (error.response?.status === 404) {\n          logger.warn(`No representatives found for postal code: ${postalCode}`);\n          return [];\n        }\n\n        if (error.response?.status === 429) {\n          logger.error('Represent API rate limit exceeded');\n          throw new Error('Rate limit exceeded. Please try again later.');\n        }\n      }\n\n      logger.error('Represent API error:', error);\n      throw new Error('Failed to fetch representatives');\n    }\n  }\n\n  private extractRepresentId(url: string): string {\n    // Extract ID from URL: /representatives/house-of-commons/123/\n    const match = url.match(/\\/representatives\\/[^\\/]+\\/(\\d+)\\//);\n    return match ? match[1] : url;\n  }\n\n  private mapGovernmentLevel(setName: string): string {\n    // Map representative set names to standard levels\n    const lowerSetName = setName.toLowerCase();\n\n    if (lowerSetName.includes('house-of-commons')) return 'federal';\n    if (lowerSetName.includes('legislative-assembly')) return 'provincial';\n    if (lowerSetName.includes('council')) return 'municipal';\n\n    return 'other';\n  }\n}\n
"},{"location":"v2/features/influence/representatives/#frontend-representative-card-component","title":"Frontend: Representative Card Component","text":"
// admin/src/components/influence/RepresentativeCard.tsx\n\nimport React from 'react';\nimport { Card, Avatar, Space, Typography, Tag, Button } from 'antd';\nimport { MailOutlined, UserOutlined } from '@ant-design/icons';\nimport type { Representative } from '../../types/api';\n\ninterface RepresentativeCardProps {\n  representative: Representative;\n  onSelect?: (id: string) => void;\n  selected?: boolean;\n}\n\nconst RepresentativeCard: React.FC<RepresentativeCardProps> = ({\n  representative,\n  onSelect,\n  selected\n}) => {\n  const levelColors: Record<string, string> = {\n    federal: 'blue',\n    provincial: 'green',\n    municipal: 'orange'\n  };\n\n  return (\n    <Card\n      hoverable={!!onSelect}\n      onClick={() => onSelect?.(representative.id)}\n      style={{\n        borderColor: selected ? '#1890ff' : undefined,\n        borderWidth: selected ? 2 : 1\n      }}\n    >\n      <Space align=\"start\" size=\"large\">\n        <Avatar\n          src={representative.photoUrl}\n          icon={<UserOutlined />}\n          size={80}\n        />\n\n        <Space direction=\"vertical\" size={0} style={{ flex: 1 }}>\n          <Typography.Title level={5} style={{ margin: 0 }}>\n            {representative.name}\n          </Typography.Title>\n\n          <Typography.Text type=\"secondary\">\n            {representative.electedOffice}\n          </Typography.Text>\n\n          <Typography.Text type=\"secondary\">\n            {representative.districtName}\n          </Typography.Text>\n\n          <Space size=\"small\" style={{ marginTop: 8 }}>\n            <Tag color={levelColors[representative.level] || 'default'}>\n              {representative.level.toUpperCase()}\n            </Tag>\n\n            {representative.partyName && (\n              <Tag>{representative.partyName}</Tag>\n            )}\n          </Space>\n\n          {representative.email && (\n            <Button\n              type=\"link\"\n              icon={<MailOutlined />}\n              href={`mailto:${representative.email}`}\n              style={{ padding: 0, marginTop: 8 }}\n            >\n              {representative.email}\n            </Button>\n          )}\n        </Space>\n      </Space>\n    </Card>\n  );\n};\n\nexport default RepresentativeCard;\n
"},{"location":"v2/features/influence/representatives/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/influence/representatives/#no-representatives-found","title":"No Representatives Found","text":"

Symptoms: - Lookup returns empty array - Error: \"No representatives found for this postal code\"

Solutions:

  1. Verify postal code format \u2192 Must be valid Canadian postal code
  2. Check Represent API status \u2192 Visit https://represent.opennorth.ca/health
  3. Test postal code manually \u2192 Try https://represent.opennorth.ca/postcodes/K1A0A1/
  4. Review API logs \u2192 Check for rate limit errors

Debugging:

# Test Represent API directly\ncurl https://represent.opennorth.ca/postcodes/K1A0A1/ | jq\n\n# Check representative cache\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_lite -c \\\n  \"SELECT * FROM representatives WHERE postal_code = 'K1A0A1';\"\n\n# Check API logs\ndocker compose logs api | grep \"Represent API\"\n
"},{"location":"v2/features/influence/representatives/#rate-limit-exceeded","title":"Rate Limit Exceeded","text":"

Symptoms: - HTTP 429 error - Error: \"Rate limit exceeded. Please try again later.\"

Solutions:

  1. Implement exponential backoff \u2192 Retry with increasing delays
  2. Use cache more aggressively \u2192 Increase cache TTL to 60 days
  3. Batch lookups \u2192 Avoid rapid repeated lookups
  4. Contact Open North \u2192 Request rate limit increase if needed

Code Fix (represent-api.client.ts):

async getRepresentativesByPostalCodeWithRetry(\n  postalCode: string,\n  maxRetries = 3\n): Promise<any[]> {\n  for (let i = 0; i < maxRetries; i++) {\n    try {\n      return await this.getRepresentativesByPostalCode(postalCode);\n    } catch (error) {\n      if (error.message.includes('Rate limit exceeded')) {\n        const delay = Math.pow(2, i) * 1000; // Exponential backoff\n        logger.warn(`Rate limit hit, retrying in ${delay}ms...`);\n        await new Promise(resolve => setTimeout(resolve, delay));\n        continue;\n      }\n      throw error;\n    }\n  }\n\n  throw new Error('Max retries exceeded');\n}\n
"},{"location":"v2/features/influence/representatives/#stale-representative-information","title":"Stale Representative Information","text":"

Symptoms: - Representative email bounces - Representative no longer in office

Solutions:

  1. Clear cache for postal code \u2192 Delete and re-fetch
  2. Reduce cache TTL \u2192 Set REPRESENT_CACHE_TTL to 7 days (604800)
  3. Manual verification \u2192 Check official government websites
  4. Report to Represent API \u2192 If data is incorrect, report to Open North

Manual Cache Clear:

// Via admin UI\n// Navigate to Influence > Representatives\n// Find postal code in table\n// Click \"Delete All\" for that postal code\n\n// Via API\nawait api.delete(`/representatives/postal-code/${postalCode}`);\n
"},{"location":"v2/features/influence/representatives/#missing-email-addresses","title":"Missing Email Addresses","text":"

Symptoms: - Representative has no email address - Cannot send campaign email

Solutions:

  1. Check Represent API data \u2192 Some reps don't provide email publicly
  2. Use manual email field \u2192 Allow admins to add email addresses
  3. Fallback to constituency office \u2192 Use office email if available
  4. Skip representative \u2192 Don't include in email recipients

Code Fix (representative.service.ts):

async updateRepresentativeEmail(\n  representId: string,\n  email: string\n): Promise<Representative> {\n  return this.prisma.representative.update({\n    where: { representId },\n    data: {\n      email,\n      lastUpdated: new Date() // Reset cache timestamp\n    }\n  });\n}\n
"},{"location":"v2/features/influence/representatives/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/influence/representatives/#cache-strategy","title":"Cache Strategy","text":"

TTL Configuration:

Cache Warming:

Pre-populate cache for common postal codes:

// api/src/scripts/warm-representative-cache.ts\n\nimport { RepresentativeService } from '../modules/influence/representatives/representatives.service';\nimport { PrismaClient } from '@prisma/client';\n\nconst prisma = new PrismaClient();\nconst representativeService = new RepresentativeService(prisma);\n\n// Common postal codes from campaign participation data\nconst commonPostalCodes = [\n  'K1A0A1', 'M5H2N2', 'V6B1A1', // Federal capitals\n  'T2P2M5', 'H3B1A1', 'S7K0J5'  // Provincial capitals\n];\n\nasync function warmCache() {\n  for (const postalCode of commonPostalCodes) {\n    try {\n      await representativeService.lookupByPostalCode(postalCode);\n      console.log(`Cached representatives for ${postalCode}`);\n    } catch (error) {\n      console.error(`Failed to cache ${postalCode}:`, error);\n    }\n\n    // Rate limit: 1 request per second\n    await new Promise(resolve => setTimeout(resolve, 1000));\n  }\n}\n\nwarmCache();\n
"},{"location":"v2/features/influence/representatives/#query-optimization","title":"Query Optimization","text":"

Index Usage:

-- Composite index for fast lookups\nCREATE INDEX idx_representative_postal_code_level\n  ON representatives (postal_code, level);\n\n-- Index for cache invalidation\nCREATE INDEX idx_representative_last_updated\n  ON representatives (last_updated);\n

Query Pattern:

// Optimized cache lookup with index\nconst cached = await prisma.representative.findMany({\n  where: {\n    postalCode: normalized,\n    level: { in: targetLevels }, // Use index\n    lastUpdated: {\n      gte: new Date(Date.now() - CACHE_TTL * 1000)\n    }\n  }\n});\n
"},{"location":"v2/features/influence/representatives/#api-rate-limiting","title":"API Rate Limiting","text":"

Client-Side Rate Limiter:

import Bottleneck from 'bottleneck';\n\nconst limiter = new Bottleneck({\n  maxConcurrent: 1,\n  minTime: 1000 // 1 request per second\n});\n\nconst getRepresentativesRateLimited = limiter.wrap(\n  representApiClient.getRepresentativesByPostalCode.bind(representApiClient)\n);\n

Redis-Based Distributed Rate Limiting:

import { RateLimiterRedis } from 'rate-limiter-flexible';\n\nconst rateLimiter = new RateLimiterRedis({\n  storeClient: redisClient,\n  keyPrefix: 'represent-api',\n  points: 60, // 60 requests\n  duration: 60 // per minute\n});\n\nawait rateLimiter.consume('represent-api-key');\n
"},{"location":"v2/features/influence/representatives/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/influence/representatives/#backend-modules","title":"Backend Modules","text":""},{"location":"v2/features/influence/representatives/#frontend-pages","title":"Frontend Pages","text":""},{"location":"v2/features/influence/representatives/#database-models_1","title":"Database Models","text":""},{"location":"v2/features/influence/representatives/#external-apis","title":"External APIs","text":""},{"location":"v2/features/influence/representatives/#configuration_1","title":"Configuration","text":""},{"location":"v2/features/influence/responses/","title":"Response Wall System","text":""},{"location":"v2/features/influence/responses/#overview","title":"Overview","text":"

The response wall system allows campaign participants to share their advocacy actions publicly, creating social proof and encouraging further participation. It includes email verification, admin moderation, upvoting capabilities, and screenshot uploads to showcase genuine participation.

Key Capabilities:

Use Cases:

"},{"location":"v2/features/influence/responses/#architecture","title":"Architecture","text":"
graph TD\n    A[Public User] -->|Submit Response| B[ResponseWallPage]\n    B -->|POST /api/public/responses| C[Response Service]\n    C -->|Save| D[(Response Model)]\n    C -->|Send| E[Email Service]\n    E -->|Verification Email| F[User Inbox]\n\n    F -->|Click Link| G[Verify Endpoint]\n    G -->|Update| D\n\n    H[Admin User] -->|Review| I[ResponsesPage]\n    I -->|GET /api/responses| C\n    I -->|Approve/Reject| C\n    C -->|Update Status| D\n\n    J[Public User] -->|View Wall| K[ResponseWallPage]\n    K -->|GET /api/public/responses/:campaignId| C\n    C -->|Filter APPROVED| D\n\n    K -->|Upvote| L[Upvote Service]\n    L -->|Track| M[(ResponseUpvote Model)]\n    L -->|Increment| D\n\n    style D fill:#e1f5ff\n    style M fill:#e1f5ff\n    style E fill:#fff4e1

Flow Description:

  1. User submits response \u2192 Response service saves with PENDING status
  2. Verification email sent \u2192 User clicks link to verify email
  3. Email verified \u2192 Response marked as email verified
  4. Admin reviews \u2192 Moderates response (approve/reject)
  5. Response approved \u2192 Appears on public response wall
  6. Users upvote \u2192 Upvote service tracks votes, increments count
  7. Public views wall \u2192 Only approved responses displayed
"},{"location":"v2/features/influence/responses/#database-models","title":"Database Models","text":""},{"location":"v2/features/influence/responses/#response-model","title":"Response Model","text":"

See Response Model Documentation for full schema.

Key Fields:

Field Type Description id String (UUID) Primary key campaignId String Associated campaign responseType Enum EMAIL, LETTER, PHONE_CALL, MEETING, SOCIAL_MEDIA, OTHER message String User's response message screenshotUrl String? Uploaded screenshot URL name String Submitter's name email String Submitter's email postalCode String? Submitter's postal code isEmailVerified Boolean Email verification status status Enum PENDING, APPROVED, REJECTED upvotes Int Number of upvotes moderatedByUserId String? Admin who moderated moderationNotes String? Admin notes

Indexes:

"},{"location":"v2/features/influence/responses/#responseupvote-model","title":"ResponseUpvote Model","text":"

See ResponseUpvote Model Documentation for full schema.

Key Fields:

Field Type Description id String (UUID) Primary key responseId String Associated response ipAddress String? Voter IP address userId String? Voter user ID (if logged in)

Constraints:

Related Models:

"},{"location":"v2/features/influence/responses/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/influence/responses/#admin-endpoints","title":"Admin Endpoints","text":"

See Responses Module API Reference for full details.

Method Endpoint Auth Description GET /api/responses SUPER_ADMIN, INFLUENCE_ADMIN List all responses (paginated, filterable) GET /api/responses/:id SUPER_ADMIN, INFLUENCE_ADMIN Get response details PATCH /api/responses/:id/moderate SUPER_ADMIN, INFLUENCE_ADMIN Approve/reject response DELETE /api/responses/:id SUPER_ADMIN Delete response"},{"location":"v2/features/influence/responses/#public-endpoints","title":"Public Endpoints","text":"

See Responses Module API Reference.

Method Endpoint Auth Description GET /api/public/responses/:campaignId None List approved responses for campaign POST /api/public/responses None Submit new response GET /api/public/responses/verify/:token None Verify email via token POST /api/public/responses/:id/upvote None Upvote response (IP/user tracked)"},{"location":"v2/features/influence/responses/#configuration","title":"Configuration","text":""},{"location":"v2/features/influence/responses/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description EMAIL_TEST_MODE boolean false Send verification emails to MailHog SMTP_FROM_EMAIL string - Sender email for verification SMTP_FROM_NAME string - Sender name for verification RESPONSE_VERIFICATION_URL string - Base URL for verification links"},{"location":"v2/features/influence/responses/#campaign-feature-flags","title":"Campaign Feature Flags","text":"

Response wall behavior configured per campaign:

Flag Description showResponseWall Enable response wall for campaign requireEmailVerification Require email verification before display allowAnonymousResponses Allow submissions without login"},{"location":"v2/features/influence/responses/#upload-configuration","title":"Upload Configuration","text":"

Screenshots uploaded to /uploads/responses/{responseId}/{filename}.

Limits: - Max file size: 5MB - Allowed formats: jpg, jpeg, png, gif, webp

"},{"location":"v2/features/influence/responses/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/influence/responses/#1-view-pending-responses","title":"1. View Pending Responses","text":"

[Screenshot: ResponsesPage with pending filter active]

Steps:

  1. Navigate to Influence > Responses
  2. Click Pending filter tab
  3. View pending responses requiring moderation
  4. Sort by submission date (newest first)

Code Example (ResponsesPage.tsx):

const [responses, setResponses] = useState<Response[]>([]);\nconst [filter, setFilter] = useState<'all' | 'pending' | 'approved' | 'rejected'>('pending');\n\nuseEffect(() => {\n  const fetchResponses = async () => {\n    const params = new URLSearchParams();\n\n    if (filter !== 'all') {\n      params.set('status', filter.toUpperCase());\n    }\n\n    const { data } = await api.get(`/responses?${params.toString()}`);\n    setResponses(data.responses);\n  };\n\n  fetchResponses();\n}, [filter]);\n\nreturn (\n  <Card>\n    <Tabs activeKey={filter} onChange={setFilter}>\n      <TabPane tab=\"Pending\" key=\"pending\" />\n      <TabPane tab=\"Approved\" key=\"approved\" />\n      <TabPane tab=\"Rejected\" key=\"rejected\" />\n      <TabPane tab=\"All\" key=\"all\" />\n    </Tabs>\n\n    <Table dataSource={responses} columns={columns} />\n  </Card>\n);\n
"},{"location":"v2/features/influence/responses/#2-review-response-details","title":"2. Review Response Details","text":"

[Screenshot: Response detail drawer with full content]

Steps:

  1. Click View on response row
  2. Review response details:
  3. Campaign name
  4. Response type
  5. Submitter name and email
  6. Message content
  7. Screenshot (if uploaded)
  8. Email verification status
  9. Submission date
  10. Check for spam/inappropriate content

Moderation Checklist:

"},{"location":"v2/features/influence/responses/#3-approve-or-reject-response","title":"3. Approve or Reject Response","text":"

[Screenshot: Response detail drawer with approve/reject buttons]

Steps:

  1. Click Approve or Reject button
  2. Add moderation notes (optional but recommended)
  3. Confirm action
  4. Response status updated
  5. If approved \u2192 appears on public response wall
  6. If rejected \u2192 hidden from public, admin can view

Code Example (responses.service.ts):

async moderateResponse(\n  responseId: string,\n  status: 'APPROVED' | 'REJECTED',\n  moderatorUserId: string,\n  notes?: string\n): Promise<Response> {\n  const response = await this.prisma.response.update({\n    where: { id: responseId },\n    data: {\n      status,\n      moderatedByUserId: moderatorUserId,\n      moderationNotes: notes,\n      moderatedAt: new Date()\n    },\n    include: {\n      campaign: true,\n      moderatedBy: {\n        select: { name: true, email: true }\n      }\n    }\n  });\n\n  // Send notification email if campaign has notifyOnResponse enabled\n  if (status === 'APPROVED' && response.campaign.notifyOnResponse) {\n    await this.emailService.send({\n      to: response.email,\n      subject: `Your response was approved`,\n      template: 'response-approved',\n      variables: {\n        name: response.name,\n        campaignTitle: response.campaign.title,\n        responseWallUrl: `${process.env.FRONTEND_URL}/responses/${response.campaignId}`\n      }\n    });\n  }\n\n  logger.info(`Response ${responseId} ${status} by user ${moderatorUserId}`);\n\n  return response;\n}\n
"},{"location":"v2/features/influence/responses/#4-bulk-moderation-actions","title":"4. Bulk Moderation Actions","text":"

[Screenshot: ResponsesPage with bulk action toolbar]

Steps:

  1. Select multiple responses (checkboxes)
  2. Click Bulk Actions dropdown
  3. Choose action:
  4. Approve selected
  5. Reject selected
  6. Delete selected
  7. Confirm bulk action
  8. All selected responses updated

Code Example (ResponsesPage.tsx):

const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);\n\nconst handleBulkApprove = async () => {\n  try {\n    await Promise.all(\n      selectedRowKeys.map(id =>\n        api.patch(`/responses/${id}/moderate`, {\n          status: 'APPROVED',\n          notes: 'Bulk approved'\n        })\n      )\n    );\n\n    message.success(`Approved ${selectedRowKeys.length} responses`);\n    setSelectedRowKeys([]);\n    fetchResponses();\n  } catch (error) {\n    message.error('Failed to bulk approve responses');\n  }\n};\n\nconst rowSelection = {\n  selectedRowKeys,\n  onChange: setSelectedRowKeys\n};\n\nreturn (\n  <>\n    {selectedRowKeys.length > 0 && (\n      <Space style={{ marginBottom: 16 }}>\n        <Button onClick={handleBulkApprove}>Approve Selected</Button>\n        <Button onClick={handleBulkReject}>Reject Selected</Button>\n        <Button danger onClick={handleBulkDelete}>Delete Selected</Button>\n      </Space>\n    )}\n\n    <Table rowSelection={rowSelection} dataSource={responses} columns={columns} />\n  </>\n);\n
"},{"location":"v2/features/influence/responses/#public-workflow","title":"Public Workflow","text":""},{"location":"v2/features/influence/responses/#1-submit-response","title":"1. Submit Response","text":"

[Screenshot: Response submission form on ResponseWallPage]

User Journey:

  1. User completes campaign action (sends email)
  2. Clicks Share Your Response link
  3. Navigated to /responses/{campaignId}/submit
  4. Fills in response form:
  5. Response type (dropdown)
  6. Name
  7. Email
  8. Postal code (optional)
  9. Message (what they did)
  10. Screenshot (optional upload)
  11. Clicks Submit Response
  12. System saves response as PENDING
  13. Verification email sent (if required)

Code Example (ResponseWallPage.tsx):

const handleSubmit = async (values: any) => {\n  try {\n    const formData = new FormData();\n    formData.append('campaignId', campaignId);\n    formData.append('responseType', values.responseType);\n    formData.append('name', values.name);\n    formData.append('email', values.email);\n    formData.append('message', values.message);\n\n    if (values.postalCode) {\n      formData.append('postalCode', values.postalCode);\n    }\n\n    if (values.screenshot?.[0]?.originFileObj) {\n      formData.append('screenshot', values.screenshot[0].originFileObj);\n    }\n\n    await axios.post('/api/public/responses', formData, {\n      headers: { 'Content-Type': 'multipart/form-data' }\n    });\n\n    if (campaign.requireEmailVerification) {\n      message.success('Response submitted! Please check your email to verify.');\n    } else {\n      message.success('Response submitted! It will appear after admin approval.');\n    }\n\n    form.resetFields();\n  } catch (error) {\n    message.error('Failed to submit response');\n  }\n};\n\nreturn (\n  <Form form={form} onFinish={handleSubmit} layout=\"vertical\">\n    <Form.Item\n      name=\"responseType\"\n      label=\"What did you do?\"\n      rules={[{ required: true }]}\n    >\n      <Select>\n        <Option value=\"EMAIL\">Sent Email</Option>\n        <Option value=\"LETTER\">Sent Letter</Option>\n        <Option value=\"PHONE_CALL\">Made Phone Call</Option>\n        <Option value=\"MEETING\">Attended Meeting</Option>\n        <Option value=\"SOCIAL_MEDIA\">Posted on Social Media</Option>\n        <Option value=\"OTHER\">Other</Option>\n      </Select>\n    </Form.Item>\n\n    <Form.Item\n      name=\"name\"\n      label=\"Your Name\"\n      rules={[{ required: true }]}\n    >\n      <Input />\n    </Form.Item>\n\n    <Form.Item\n      name=\"email\"\n      label=\"Your Email\"\n      rules={[\n        { required: true },\n        { type: 'email' }\n      ]}\n    >\n      <Input />\n    </Form.Item>\n\n    <Form.Item\n      name=\"message\"\n      label=\"Tell us more\"\n      rules={[{ required: true }]}\n    >\n      <Input.TextArea rows={4} placeholder=\"Describe what you did...\" />\n    </Form.Item>\n\n    <Form.Item\n      name=\"screenshot\"\n      label=\"Upload Screenshot (optional)\"\n      valuePropName=\"fileList\"\n      getValueFromEvent={e => Array.isArray(e) ? e : e?.fileList}\n    >\n      <Upload\n        listType=\"picture\"\n        maxCount={1}\n        beforeUpload={() => false}\n      >\n        <Button icon={<UploadOutlined />}>Upload Screenshot</Button>\n      </Upload>\n    </Form.Item>\n\n    <Form.Item>\n      <Button type=\"primary\" htmlType=\"submit\">\n        Submit Response\n      </Button>\n    </Form.Item>\n  </Form>\n);\n
"},{"location":"v2/features/influence/responses/#2-verify-email","title":"2. Verify Email","text":"

[Screenshot: Email verification success page]

User Journey:

  1. User receives verification email
  2. Clicks verification link
  3. Navigated to /api/public/responses/verify/{token}
  4. System verifies email
  5. Response marked as email verified
  6. User redirected to response wall
  7. Message: \"Email verified! Your response will appear after admin approval.\"

Verification Email Template:

<h1>Verify Your Response</h1>\n\n<p>Hi {{name}},</p>\n\n<p>Thanks for sharing your response to <strong>{{campaignTitle}}</strong>!</p>\n\n<p>Please verify your email address by clicking the link below:</p>\n\n<p>\n  <a href=\"{{verificationUrl}}\">Verify Email</a>\n</p>\n\n<p>This link will expire in 24 hours.</p>\n\n<p>If you didn't submit this response, you can safely ignore this email.</p>\n
"},{"location":"v2/features/influence/responses/#3-view-response-wall","title":"3. View Response Wall","text":"

[Screenshot: Public response wall with approved responses]

User Journey:

  1. User visits /responses/{campaignId}
  2. Sees approved responses
  3. Responses sorted by upvotes (most upvoted first)
  4. Can upvote responses
  5. Can filter by response type

Code Example (ResponseWallPage.tsx):

const [responses, setResponses] = useState<Response[]>([]);\nconst [filter, setFilter] = useState<string | null>(null);\n\nuseEffect(() => {\n  const fetchResponses = async () => {\n    const params = new URLSearchParams();\n\n    if (filter) {\n      params.set('responseType', filter);\n    }\n\n    const { data } = await axios.get(\n      `/api/public/responses/${campaignId}?${params.toString()}`\n    );\n\n    setResponses(data);\n  };\n\n  fetchResponses();\n}, [campaignId, filter]);\n\nreturn (\n  <PublicLayout>\n    <Space direction=\"vertical\" size=\"large\" style={{ width: '100%' }}>\n      <Typography.Title level={2}>Response Wall</Typography.Title>\n\n      <Radio.Group value={filter} onChange={e => setFilter(e.target.value)}>\n        <Radio.Button value={null}>All</Radio.Button>\n        <Radio.Button value=\"EMAIL\">Emails</Radio.Button>\n        <Radio.Button value=\"LETTER\">Letters</Radio.Button>\n        <Radio.Button value=\"PHONE_CALL\">Calls</Radio.Button>\n        <Radio.Button value=\"MEETING\">Meetings</Radio.Button>\n        <Radio.Button value=\"SOCIAL_MEDIA\">Social Media</Radio.Button>\n      </Radio.Group>\n\n      <List\n        dataSource={responses}\n        renderItem={response => (\n          <ResponseCard\n            response={response}\n            onUpvote={handleUpvote}\n          />\n        )}\n      />\n    </Space>\n  </PublicLayout>\n);\n
"},{"location":"v2/features/influence/responses/#4-upvote-response","title":"4. Upvote Response","text":"

[Screenshot: Response card with upvote button]

User Journey:

  1. User clicks upvote button on response
  2. System checks for existing upvote (IP + user)
  3. If first upvote \u2192 increment count, save upvote record
  4. If already upvoted \u2192 show message \"You already upvoted this\"
  5. Upvote count updated in real-time

Code Example (responses-public.routes.ts):

router.post('/:id/upvote', async (req, res) => {\n  try {\n    const { id } = req.params;\n    const ipAddress = req.ip;\n    const userId = req.user?.id; // If authenticated\n\n    // Check for existing upvote\n    const existingUpvote = await prisma.responseUpvote.findFirst({\n      where: {\n        responseId: id,\n        OR: [\n          { ipAddress },\n          userId ? { userId } : {}\n        ]\n      }\n    });\n\n    if (existingUpvote) {\n      return res.status(400).json({ error: 'You already upvoted this response' });\n    }\n\n    // Create upvote and increment count (transaction)\n    await prisma.$transaction([\n      prisma.responseUpvote.create({\n        data: {\n          responseId: id,\n          ipAddress,\n          userId\n        }\n      }),\n      prisma.response.update({\n        where: { id },\n        data: {\n          upvotes: { increment: 1 }\n        }\n      })\n    ]);\n\n    res.json({ success: true });\n  } catch (error) {\n    logger.error('Failed to upvote response:', error);\n    res.status(500).json({ error: 'Failed to upvote response' });\n  }\n});\n
"},{"location":"v2/features/influence/responses/#volunteer-workflow","title":"Volunteer Workflow","text":"

Not applicable \u2014 response wall is public-facing.

"},{"location":"v2/features/influence/responses/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/influence/responses/#backend-email-verification-token","title":"Backend: Email Verification Token","text":"
// api/src/modules/influence/responses/responses.service.ts\n\nimport crypto from 'crypto';\n\nasync createResponse(data: any): Promise<Response> {\n  const verificationToken = crypto.randomBytes(32).toString('hex');\n\n  const response = await this.prisma.response.create({\n    data: {\n      ...data,\n      status: 'PENDING',\n      isEmailVerified: false,\n      verificationToken,\n      verificationTokenExpires: new Date(Date.now() + 24 * 60 * 60 * 1000) // 24h\n    }\n  });\n\n  // Send verification email\n  await this.emailService.send({\n    to: response.email,\n    subject: 'Verify your response',\n    template: 'response-verification',\n    variables: {\n      name: response.name,\n      campaignTitle: response.campaign.title,\n      verificationUrl: `${process.env.RESPONSE_VERIFICATION_URL}/api/public/responses/verify/${verificationToken}`\n    }\n  });\n\n  return response;\n}\n\nasync verifyEmail(token: string): Promise<Response> {\n  const response = await this.prisma.response.findFirst({\n    where: {\n      verificationToken: token,\n      verificationTokenExpires: {\n        gt: new Date()\n      }\n    }\n  });\n\n  if (!response) {\n    throw new Error('Invalid or expired verification token');\n  }\n\n  return this.prisma.response.update({\n    where: { id: response.id },\n    data: {\n      isEmailVerified: true,\n      verificationToken: null,\n      verificationTokenExpires: null\n    }\n  });\n}\n
"},{"location":"v2/features/influence/responses/#frontend-response-card-component","title":"Frontend: Response Card Component","text":"
// admin/src/components/influence/ResponseCard.tsx\n\nimport React from 'react';\nimport { Card, Space, Typography, Tag, Button, Avatar } from 'antd';\nimport { LikeOutlined, LikeFilled } from '@ant-design/icons';\nimport type { Response } from '../../types/api';\n\ninterface ResponseCardProps {\n  response: Response;\n  onUpvote: (id: string) => void;\n  hasUpvoted?: boolean;\n}\n\nconst ResponseCard: React.FC<ResponseCardProps> = ({\n  response,\n  onUpvote,\n  hasUpvoted\n}) => {\n  const typeColors: Record<string, string> = {\n    EMAIL: 'blue',\n    LETTER: 'green',\n    PHONE_CALL: 'orange',\n    MEETING: 'purple',\n    SOCIAL_MEDIA: 'cyan',\n    OTHER: 'default'\n  };\n\n  const typeLabels: Record<string, string> = {\n    EMAIL: 'Sent Email',\n    LETTER: 'Sent Letter',\n    PHONE_CALL: 'Made Call',\n    MEETING: 'Attended Meeting',\n    SOCIAL_MEDIA: 'Posted on Social Media',\n    OTHER: 'Other Action'\n  };\n\n  return (\n    <Card>\n      <Space direction=\"vertical\" size=\"small\" style={{ width: '100%' }}>\n        <Space>\n          <Avatar>{response.name[0].toUpperCase()}</Avatar>\n          <Space direction=\"vertical\" size={0}>\n            <Typography.Text strong>{response.name}</Typography.Text>\n            <Typography.Text type=\"secondary\">\n              {new Date(response.createdAt).toLocaleDateString()}\n            </Typography.Text>\n          </Space>\n        </Space>\n\n        <Tag color={typeColors[response.responseType]}>\n          {typeLabels[response.responseType]}\n        </Tag>\n\n        <Typography.Paragraph>{response.message}</Typography.Paragraph>\n\n        {response.screenshotUrl && (\n          <img\n            src={response.screenshotUrl}\n            alt=\"Response screenshot\"\n            style={{ maxWidth: '100%', borderRadius: 4 }}\n          />\n        )}\n\n        <Button\n          type=\"text\"\n          icon={hasUpvoted ? <LikeFilled /> : <LikeOutlined />}\n          onClick={() => onUpvote(response.id)}\n          disabled={hasUpvoted}\n        >\n          {response.upvotes} {response.upvotes === 1 ? 'upvote' : 'upvotes'}\n        </Button>\n      </Space>\n    </Card>\n  );\n};\n\nexport default ResponseCard;\n
"},{"location":"v2/features/influence/responses/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/influence/responses/#verification-email-not-received","title":"Verification Email Not Received","text":"

Symptoms: - User doesn't receive verification email - Email not in spam folder

Solutions:

  1. Check email service logs \u2192 docker compose logs api | grep \"verification\"
  2. Verify SMTP configuration \u2192 test with /api/auth/test-email
  3. Check EMAIL_TEST_MODE \u2192 if true, email sent to MailHog (localhost:8025)
  4. Resend verification email \u2192 manual resend via admin UI

Manual Resend:

// Admin UI: ResponsesPage\nconst handleResendVerification = async (responseId: string) => {\n  await api.post(`/responses/${responseId}/resend-verification`);\n  message.success('Verification email resent');\n};\n
"},{"location":"v2/features/influence/responses/#duplicate-upvotes","title":"Duplicate Upvotes","text":"

Symptoms: - User can upvote same response multiple times - Upvote count inflated

Solutions:

  1. Check database constraints \u2192 should have unique constraint on responseId, ipAddress
  2. Verify transaction \u2192 upvote creation and count increment must be atomic
  3. Check IP address extraction \u2192 ensure req.ip is correct (consider X-Forwarded-For)

Database Fix:

-- Add unique constraint if missing\nALTER TABLE response_upvotes\nADD CONSTRAINT unique_response_ip\nUNIQUE (response_id, ip_address);\n\nALTER TABLE response_upvotes\nADD CONSTRAINT unique_response_user\nUNIQUE (response_id, user_id)\nWHERE user_id IS NOT NULL;\n
"},{"location":"v2/features/influence/responses/#screenshot-upload-fails","title":"Screenshot Upload Fails","text":"

Symptoms: - Upload spinner never completes - Error: \"File too large\"

Solutions:

  1. Check file size \u2192 max 5MB
  2. Verify file format \u2192 must be image (jpg/jpeg/png/gif/webp)
  3. Check upload directory permissions \u2192 /uploads/responses must be writable
  4. Increase Nginx upload limit \u2192 client_max_body_size 10M;

Code Fix (responses.service.ts):

import sharp from 'sharp';\n\nasync uploadScreenshot(\n  file: Express.Multer.File,\n  responseId: string\n): Promise<string> {\n  const uploadDir = `/uploads/responses/${responseId}`;\n  await fs.mkdir(uploadDir, { recursive: true });\n\n  const filename = `${Date.now()}-${file.originalname}`;\n\n  // Optimize image (max 1200px width, 85% quality)\n  await sharp(file.buffer)\n    .resize(1200, null, { withoutEnlargement: true })\n    .jpeg({ quality: 85 })\n    .toFile(`${uploadDir}/${filename}`);\n\n  return `${uploadDir}/${filename}`;\n}\n
"},{"location":"v2/features/influence/responses/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/influence/responses/#query-optimization","title":"Query Optimization","text":"

Index Strategy:

-- Composite index for public wall queries\nCREATE INDEX idx_response_campaign_status\n  ON responses (campaign_id, status)\n  WHERE status = 'APPROVED';\n\n-- Index for sorting by upvotes\nCREATE INDEX idx_response_upvotes\n  ON responses (upvotes DESC);\n\n-- Index for email verification lookups\nCREATE INDEX idx_response_verification_token\n  ON responses (verification_token)\n  WHERE verification_token IS NOT NULL;\n

Optimized Public Query:

const responses = await prisma.response.findMany({\n  where: {\n    campaignId,\n    status: 'APPROVED',\n    isEmailVerified: true\n  },\n  orderBy: [\n    { upvotes: 'desc' },\n    { createdAt: 'desc' }\n  ],\n  take: 50,\n  skip: page * 50\n});\n
"},{"location":"v2/features/influence/responses/#caching-strategy","title":"Caching Strategy","text":"

Redis Caching for Response Wall:

import { redisClient } from '../../../config/redis';\n\nasync getApprovedResponses(campaignId: string): Promise<Response[]> {\n  const cacheKey = `responses:${campaignId}`;\n\n  // Check cache\n  const cached = await redisClient.get(cacheKey);\n  if (cached) {\n    return JSON.parse(cached);\n  }\n\n  // Query database\n  const responses = await prisma.response.findMany({\n    where: {\n      campaignId,\n      status: 'APPROVED',\n      isEmailVerified: true\n    },\n    orderBy: { upvotes: 'desc' }\n  });\n\n  // Cache for 5 minutes\n  await redisClient.setex(cacheKey, 300, JSON.stringify(responses));\n\n  return responses;\n}\n\n// Invalidate cache on moderation\nasync moderateResponse(responseId: string, status: string) {\n  const response = await prisma.response.update({\n    where: { id: responseId },\n    data: { status }\n  });\n\n  // Invalidate cache\n  await redisClient.del(`responses:${response.campaignId}`);\n\n  return response;\n}\n
"},{"location":"v2/features/influence/responses/#screenshot-optimization","title":"Screenshot Optimization","text":"

Image Processing Pipeline:

import sharp from 'sharp';\n\nasync optimizeScreenshot(file: Express.Multer.File): Promise<Buffer> {\n  return sharp(file.buffer)\n    .resize(1200, null, {\n      withoutEnlargement: true,\n      fit: 'inside'\n    })\n    .jpeg({\n      quality: 85,\n      progressive: true\n    })\n    .toBuffer();\n}\n

CDN Integration:

// Upload optimized screenshots to CDN\nimport { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';\n\nconst s3 = new S3Client({ region: 'us-east-1' });\n\nasync uploadToCDN(buffer: Buffer, key: string): Promise<string> {\n  await s3.send(new PutObjectCommand({\n    Bucket: process.env.S3_BUCKET,\n    Key: `responses/${key}`,\n    Body: buffer,\n    ContentType: 'image/jpeg',\n    CacheControl: 'max-age=31536000' // 1 year\n  }));\n\n  return `${process.env.CDN_URL}/responses/${key}`;\n}\n
"},{"location":"v2/features/influence/responses/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/influence/responses/#backend-modules","title":"Backend Modules","text":""},{"location":"v2/features/influence/responses/#frontend-pages","title":"Frontend Pages","text":""},{"location":"v2/features/influence/responses/#database-models_1","title":"Database Models","text":""},{"location":"v2/features/influence/responses/#guides","title":"Guides","text":""},{"location":"v2/features/landing-pages/","title":"Landing Pages (Page Builder)","text":"

The Landing Pages feature provides a complete page building system with WYSIWYG editing, custom blocks, MkDocs export, and public rendering. Build custom landing pages without code.

"},{"location":"v2/features/landing-pages/#overview","title":"Overview","text":"

The Landing Pages system consists of four integrated components:

  1. Page Builder - Page CRUD and management
  2. GrapesJS Editor - WYSIWYG editor
  3. Block Library - Reusable content blocks
  4. MkDocs Export - Export to Jinja2 templates
"},{"location":"v2/features/landing-pages/#features","title":"Features","text":""},{"location":"v2/features/landing-pages/#wysiwyg-editor","title":"WYSIWYG Editor","text":""},{"location":"v2/features/landing-pages/#block-library","title":"Block Library","text":"

Pre-built components:

"},{"location":"v2/features/landing-pages/#page-management","title":"Page Management","text":""},{"location":"v2/features/landing-pages/#mkdocs-export","title":"MkDocs Export","text":""},{"location":"v2/features/landing-pages/#user-flow","title":"User Flow","text":""},{"location":"v2/features/landing-pages/#admin-experience","title":"Admin Experience","text":"
  1. Create Page (/app/pages)
  2. Click \"New Page\"
  3. Enter title and slug
  4. Set meta description
  5. Save draft

  6. Edit Page (/app/pages/:id/edit)

  7. Full-screen GrapesJS editor
  8. Drag blocks from sidebar
  9. Customize components
  10. Ctrl+S to save
  11. Preview changes

  12. Publish Page

  13. Set status to \"Published\"
  14. Page appears at /p/:slug
  15. Listed in page table

  16. Export to MkDocs (/app/services/docs)

  17. Select pages to export
  18. Click \"Export\"
  19. Pages saved to MkDocs overrides
  20. Rebuild MkDocs site
"},{"location":"v2/features/landing-pages/#public-experience","title":"Public Experience","text":"
  1. View Landing Page (/p/:slug)
  2. Rendered HTML/CSS
  3. Custom styling
  4. Responsive design
  5. SEO metadata
"},{"location":"v2/features/landing-pages/#architecture","title":"Architecture","text":""},{"location":"v2/features/landing-pages/#backend-components","title":"Backend Components","text":"

Module: - api/src/modules/pages/pages-admin.routes.ts - Admin CRUD - api/src/modules/pages/pages-public.routes.ts - Public renderer - api/src/modules/pages/blocks.routes.ts - Block library API - api/src/modules/pages/pages.service.ts - Business logic - api/src/modules/pages/pages.schemas.ts - Zod validation

Database Models: - Page - Page definitions (title, slug, html, css, settings) - PageBlock - Reusable block library

"},{"location":"v2/features/landing-pages/#frontend-components","title":"Frontend Components","text":"

Admin Pages: - admin/src/pages/LandingPagesPage.tsx - Page management table - admin/src/pages/PageEditorPage.tsx - Full-screen editor

Public Pages: - admin/src/pages/public/LandingPage.tsx - Page renderer

Editor Component: - admin/src/components/GrapesJSEditor.tsx - GrapesJS wrapper

"},{"location":"v2/features/landing-pages/#configuration","title":"Configuration","text":""},{"location":"v2/features/landing-pages/#environment-variables","title":"Environment Variables","text":"
# MkDocs export directory (inside Docker)\nMKDOCS_EXPORT_DIR=/mkdocs/docs/overrides\n
"},{"location":"v2/features/landing-pages/#page-settings","title":"Page Settings","text":"

Each page can configure:

"},{"location":"v2/features/landing-pages/#grapesjs-integration","title":"GrapesJS Integration","text":""},{"location":"v2/features/landing-pages/#editor-setup","title":"Editor Setup","text":"
import grapesjs from 'grapesjs';\n\nconst editor = grapesjs.init({\n  container: '#gjs',\n  fromElement: true,\n  height: '100vh',\n  storageManager: false,  // Save via API\n  canvas: {\n    styles: [...],         // Custom styles\n    scripts: [...],        // Custom scripts\n  },\n  blockManager: {\n    blocks: customBlocks,  // Block library\n  },\n});\n
"},{"location":"v2/features/landing-pages/#custom-blocks","title":"Custom Blocks","text":"

Blocks defined in GrapesJSEditor.tsx:

const customBlocks = [\n  {\n    id: 'hero-section',\n    label: 'Hero Section',\n    content: '<div class=\"hero\">...</div>',\n    category: 'Basic',\n  },\n  {\n    id: 'feature-grid',\n    label: 'Feature Grid',\n    content: '<div class=\"features\">...</div>',\n    category: 'Content',\n  },\n  // ... more blocks\n];\n
"},{"location":"v2/features/landing-pages/#save-handler","title":"Save Handler","text":"

Ctrl+S keyboard shortcut:

editor.on('run:core:save', () => {\n  const html = editor.getHtml();\n  const css = editor.getCss();\n  onSave({ html, css });\n});\n
"},{"location":"v2/features/landing-pages/#mkdocs-export_1","title":"MkDocs Export","text":""},{"location":"v2/features/landing-pages/#export-process","title":"Export Process","text":"
  1. Select Pages - Admin selects pages to export
  2. Generate Jinja2 - Wrap HTML in Material theme template
  3. Save to Overrides - Write to mkdocs/docs/overrides/
  4. Configure Front Matter - Set template, title, description
  5. Rebuild Site - MkDocs regenerates static site
"},{"location":"v2/features/landing-pages/#jinja2-template-wrapper","title":"Jinja2 Template Wrapper","text":"
{% extends \"main.html\" %}\n\n{% block content %}\n<style>\n{{ page_css }}\n</style>\n\n{{ page_html }}\n{% endblock %}\n
"},{"location":"v2/features/landing-pages/#front-matter","title":"Front Matter","text":"
---\ntemplate: custom-page.html\ntitle: Page Title\ndescription: Page description for SEO\n---\n
"},{"location":"v2/features/landing-pages/#database-schema","title":"Database Schema","text":""},{"location":"v2/features/landing-pages/#page-model","title":"Page Model","text":"
model Page {\n  id          Int      @id @default(autoincrement())\n  title       String\n  slug        String   @unique\n  html        String   @db.Text\n  css         String?  @db.Text\n  settings    Json?\n  published   Boolean  @default(false)\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n}\n
"},{"location":"v2/features/landing-pages/#pageblock-model","title":"PageBlock Model","text":"
model PageBlock {\n  id          Int      @id @default(autoincrement())\n  name        String\n  category    String\n  html        String   @db.Text\n  css         String?  @db.Text\n  thumbnail   String?\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n}\n
"},{"location":"v2/features/landing-pages/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/landing-pages/#admin-endpoints","title":"Admin Endpoints","text":"
GET    /api/pages                       # List pages\nPOST   /api/pages                       # Create page\nGET    /api/pages/:id                   # Get page\nPATCH  /api/pages/:id                   # Update page\nDELETE /api/pages/:id                   # Delete page\nPOST   /api/pages/export-mkdocs         # Export to MkDocs\nGET    /api/pages/blocks                # Get block library\nPOST   /api/pages/blocks                # Create block\n
"},{"location":"v2/features/landing-pages/#public-endpoints","title":"Public Endpoints","text":"
GET    /api/pages/public/:slug          # Get published page by slug\n
"},{"location":"v2/features/landing-pages/#desktop-only-editor","title":"Desktop-Only Editor","text":"

GrapesJS editor requires desktop browser:

const screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n\nif (isMobile) {\n  return (\n    <Alert\n      message=\"Desktop Required\"\n      description=\"Page editor requires desktop browser\"\n      type=\"warning\"\n    />\n  );\n}\n
"},{"location":"v2/features/landing-pages/#best-practices","title":"Best Practices","text":""},{"location":"v2/features/landing-pages/#slug-management","title":"Slug Management","text":""},{"location":"v2/features/landing-pages/#seo-optimization","title":"SEO Optimization","text":""},{"location":"v2/features/landing-pages/#performance","title":"Performance","text":""},{"location":"v2/features/landing-pages/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/features/landing-pages/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/map/","title":"Map Module","text":"

The Map module provides comprehensive location management, geographic organization, volunteer coordination, and door-to-door canvassing capabilities. It combines GIS features with volunteer management for effective ground campaigns.

"},{"location":"v2/features/map/#overview","title":"Overview","text":"

The Map module consists of ten integrated components:

  1. Locations - Location database with geocoding
  2. Geocoding - Multi-provider address \u2192 coordinate conversion
  3. NAR Import - Canadian electoral data import
  4. Cuts - Geographic polygon organization
  5. Shifts - Volunteer shift scheduling
  6. Canvassing - Door-to-door canvassing system
  7. Tracking - GPS tracking sessions
  8. Walk Sheets - Printable canvass materials
  9. Data Quality - Geocoding quality monitoring
  10. Map Features Status - Feature completion tracking
"},{"location":"v2/features/map/#features","title":"Features","text":""},{"location":"v2/features/map/#location-management","title":"Location Management","text":""},{"location":"v2/features/map/#geographic-organization","title":"Geographic Organization","text":""},{"location":"v2/features/map/#volunteer-coordination","title":"Volunteer Coordination","text":""},{"location":"v2/features/map/#canvassing-system","title":"Canvassing System","text":""},{"location":"v2/features/map/#map-display","title":"Map Display","text":""},{"location":"v2/features/map/#user-flow","title":"User Flow","text":""},{"location":"v2/features/map/#admin-experience","title":"Admin Experience","text":"
  1. Import Locations (/app/map/locations)
  2. Upload CSV or NAR data
  3. Geocode addresses
  4. Review quality metrics
  5. Bulk operations

  6. Create Cuts (/app/map/cuts)

  7. Draw polygons on map
  8. Name and describe cut
  9. Assign locations (automatic)
  10. Export for printing

  11. Schedule Shifts (/app/map/shifts)

  12. Create shift with cut assignment
  13. Set date/time/capacity
  14. Email all volunteers
  15. Monitor signups

  16. Monitor Canvassing (/app/canvass/dashboard)

  17. View active sessions
  18. Track visit progress
  19. Check leaderboard
  20. Review activity feed

  21. Print Materials (/app/canvass/walk-sheet)

  22. Select cut
  23. Generate walk sheet PDF
  24. QR codes for quick access
  25. Browser print
"},{"location":"v2/features/map/#volunteer-experience","title":"Volunteer Experience","text":"
  1. View Assignments (/volunteer/assignments)
  2. See upcoming shifts
  3. Cut information
  4. Start canvass button

  5. Canvass (/volunteer/canvass/:cutId)

  6. Full-screen map with GPS
  7. Follow walking route
  8. Click markers to record visits
  9. Select outcomes + notes
  10. Track progress

  11. Review Activity (/volunteer/activity)

  12. Visit history
  13. Outcome breakdown
  14. Session statistics
"},{"location":"v2/features/map/#public-experience","title":"Public Experience","text":"
  1. View Map (/map)
  2. Browse locations
  3. View cuts
  4. See visit status (color-coded)
  5. Geolocate self

  6. Sign Up for Shifts (/shifts)

  7. Browse available shifts
  8. Signup with email
  9. Receive confirmation
"},{"location":"v2/features/map/#architecture","title":"Architecture","text":""},{"location":"v2/features/map/#backend-components","title":"Backend Components","text":"

Modules: - api/src/modules/map/locations/ - Location CRUD + geocoding + NAR import - api/src/modules/map/geocoding/ - Multi-provider geocoding service - api/src/modules/map/cuts/ - Polygon CRUD + spatial queries - api/src/modules/map/shifts/ - Shift CRUD + signups - api/src/modules/map/canvass/ - Session + visit tracking - api/src/modules/map/tracking/ - GPS tracking (future) - api/src/modules/map/settings/ - Map settings singleton

Services: - api/src/services/geocoding.service.ts - Geocoding abstraction - api/src/services/geocode-queue.service.ts - Async geocoding

Utilities: - api/src/utils/spatial.ts - Point-in-polygon, haversine, bounds, centroid

Database Models: - Location - Address, coordinates, metadata, visit tracking - Cut - Name, GeoJSON polygon - Shift - Date/time, cut, capacity, signups - CanvassSession - Session tracking, start/end times - CanvassVisit - Visit outcomes, notes, GPS - MapSettings - Map center/zoom, walk sheet config

"},{"location":"v2/features/map/#frontend-components","title":"Frontend Components","text":"

Admin Pages: - admin/src/pages/LocationsPage.tsx - Location management - admin/src/pages/CutsPage.tsx - Cut management - admin/src/pages/ShiftsPage.tsx - Shift management - admin/src/pages/CanvassDashboardPage.tsx - Canvass monitoring - admin/src/pages/WalkSheetPage.tsx - Printable materials - admin/src/pages/DataQualityDashboardPage.tsx - Quality metrics

Public Pages: - admin/src/pages/public/MapPage.tsx - Public map - admin/src/pages/public/ShiftsPage.tsx - Shift signup

Volunteer Pages: - admin/src/pages/volunteer/VolunteerMapPage.tsx - GPS canvass map - admin/src/pages/volunteer/VolunteerShiftsPage.tsx - Assignments - admin/src/pages/volunteer/MyActivityPage.tsx - Activity history

Map Components: - admin/src/components/map/MapControls.tsx - Control buttons - admin/src/components/map/AddLocationMode.tsx - Click-to-add - admin/src/components/map/CutDrawingMode.tsx - Polygon drawing - admin/src/components/map/CutOverlays.tsx - GeoJSON rendering

Canvass Components: - admin/src/components/canvass/GPSTracker.tsx - GPS tracking - admin/src/components/canvass/WalkingRouteLine.tsx - Route display - admin/src/components/canvass/VisitRecordingForm.tsx - Outcome form

"},{"location":"v2/features/map/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/#environment-variables","title":"Environment Variables","text":"
# Geocoding Providers\nMAPBOX_ACCESS_TOKEN=pk_...\nGOOGLE_GEOCODE_API_KEY=...\nPELIAS_API_URL=http://pelias:4000\n\n# NAR Import\nNAR_DATA_DIR=/data           # NAR file directory (Docker volume)\n\n# Map Settings\nMAP_DEFAULT_LAT=43.65        # Default map center\nMAP_DEFAULT_LNG=-79.38\nMAP_DEFAULT_ZOOM=12\n
"},{"location":"v2/features/map/#map-settings","title":"Map Settings","text":"

Configurable via admin UI (/app/map/settings): - Default map center (lat/lng) - Default zoom level - Walk sheet header/footer - Display preferences

"},{"location":"v2/features/map/#geocoding","title":"Geocoding","text":""},{"location":"v2/features/map/#supported-providers","title":"Supported Providers","text":"
  1. Nominatim (OpenStreetMap) - Free, rate limited
  2. ArcGIS - Free tier available
  3. Photon - Free, self-hosted option
  4. Mapbox - API key required
  5. Google Geocoding - API key required
  6. Pelias - Self-hosted option
"},{"location":"v2/features/map/#geocoding-strategy","title":"Geocoding Strategy","text":"
  1. Try provider 1 (Nominatim)
  2. If fails, try provider 2 (ArcGIS)
  3. Continue through providers
  4. Cache successful results
  5. Track quality metrics
"},{"location":"v2/features/map/#bulk-geocoding","title":"Bulk Geocoding","text":""},{"location":"v2/features/map/#nar-import","title":"NAR Import","text":"

Canadian electoral data (NAR 2025 format):

Import Flow: 1. Scan NAR data directory 2. List available provinces 3. Stream Address + Location files 4. Join on LOC_GUID 5. Transform coordinates (proj4) 6. Filter and insert locations

"},{"location":"v2/features/map/#spatial-algorithms","title":"Spatial Algorithms","text":""},{"location":"v2/features/map/#point-in-polygon","title":"Point-in-Polygon","text":"

Ray-casting algorithm: - Count ray intersections with polygon edges - Odd count = inside, even count = outside - Supports holes in polygons - Used for cut assignment

"},{"location":"v2/features/map/#walking-route","title":"Walking Route","text":"

Nearest-neighbor algorithm: 1. Start at closest location to shift start point 2. For each location: - Find nearest unvisited location - Add to route - Mark as visited 3. Return ordered list

"},{"location":"v2/features/map/#haversine-distance","title":"Haversine Distance","text":"

Great-circle distance between coordinates: - Returns distance in kilometers - Used for proximity sorting - Route optimization

"},{"location":"v2/features/map/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/map/#locations","title":"Locations","text":"
GET    /api/locations                   # List locations\nPOST   /api/locations                   # Create location\nGET    /api/locations/:id               # Get location\nPATCH  /api/locations/:id               # Update location\nDELETE /api/locations/:id               # Delete location\nPOST   /api/locations/import            # CSV import\nGET    /api/locations/export            # CSV export\nPOST   /api/locations/geocode           # Bulk geocode\n
"},{"location":"v2/features/map/#cuts","title":"Cuts","text":"
GET    /api/cuts                        # List cuts\nPOST   /api/cuts                        # Create cut\nGET    /api/cuts/:id                    # Get cut\nPATCH  /api/cuts/:id                    # Update cut\nDELETE /api/cuts/:id                    # Delete cut\nPOST   /api/cuts/:id/assign-locations   # Assign locations\n
"},{"location":"v2/features/map/#shifts","title":"Shifts","text":"
GET    /api/shifts                      # List shifts\nPOST   /api/shifts                      # Create shift\nGET    /api/shifts/:id                  # Get shift\nPATCH  /api/shifts/:id                  # Update shift\nDELETE /api/shifts/:id                  # Delete shift\nPOST   /api/shifts/:id/signup           # Signup for shift\n
"},{"location":"v2/features/map/#canvassing","title":"Canvassing","text":"
POST   /api/canvass/session/start       # Start session\nPOST   /api/canvass/session/end         # End session\nGET    /api/canvass/session             # Get active session\nPOST   /api/canvass/visit               # Record visit\nGET    /api/canvass/route/:cutId        # Get walking route\nGET    /api/canvass/dashboard           # Dashboard stats\n
"},{"location":"v2/features/map/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/map/MAP_FEATURES_STATUS/","title":"Map Features Documentation Status","text":""},{"location":"v2/features/map/MAP_FEATURES_STATUS/#completion-summary","title":"Completion Summary","text":"

Date: 2026-02-13 Task: Create 9 comprehensive Map feature documentation files Status: 4/9 COMPLETE (in progress)

"},{"location":"v2/features/map/MAP_FEATURES_STATUS/#completed-files-4053-lines","title":"Completed Files (4053 lines)","text":"
  1. \u2705 locations.md (1154 lines) \u2014 Location management system
  2. Building + unit architecture
  3. NAR integration
  4. CSV import/export
  5. Geocoding integration
  6. Multi-provider support

  7. \u2705 geocoding.md (1029 lines) \u2014 Multi-provider geocoding service

  8. 6 provider fallback chain
  9. Confidence scoring
  10. Redis caching
  11. BullMQ bulk processing
  12. Provider health tracking

  13. \u2705 cuts.md (924 lines) \u2014 Geographic polygon overlays

  14. Polygon drawing workflow
  15. GeoJSON storage
  16. Point-in-polygon ray-casting
  17. Cut categories
  18. Completion tracking

  19. \u2705 shifts.md (946 lines) \u2014 Volunteer shift management

  20. Shift scheduling
  21. Capacity management
  22. Public signup
  23. TEMP user creation
  24. Email confirmations
"},{"location":"v2/features/map/MAP_FEATURES_STATUS/#remaining-files-5","title":"Remaining Files (5)","text":"
  1. \ud83d\udea7 canvassing.md \u2014 Canvassing session system
  2. Session lifecycle
  3. Visit recording
  4. Walking route algorithm
  5. GPS integration
  6. Volunteer + admin workflows

  7. \ud83d\udea7 tracking.md \u2014 GPS tracking system

  8. TrackingSession model
  9. TrackPoint recording
  10. Distance calculation
  11. Route visualization
  12. Live volunteer tracking

  13. \ud83d\udea7 walk-sheets.md \u2014 Printable walk sheets + QR codes

  14. MapSettings configuration
  15. QR code generation
  16. Walk sheet layout
  17. Cut export
  18. Browser print API

  19. \ud83d\udea7 data-quality.md \u2014 Geocoding quality dashboard

  20. Confidence metrics
  21. Provider success rate
  22. Ungeocoded locations
  23. Low-confidence alerts
  24. Duplicate detection

  25. \ud83d\udea7 nar-import.md \u2014 NAR 2025 electoral data import

  26. NAR format support
  27. Server-side streaming
  28. Address + Location join
  29. Lambert coordinate conversion
  30. Province code mapping
"},{"location":"v2/features/map/MAP_FEATURES_STATUS/#next-steps","title":"Next Steps","text":"

Continue creating remaining 5 files following the established 12-section structure:

  1. Overview
  2. Architecture (Mermaid diagram)
  3. Database Models
  4. API Endpoints
  5. Configuration
  6. Admin Workflow
  7. Public Workflow (if applicable)
  8. Volunteer Workflow (if applicable)
  9. Code Examples
  10. Troubleshooting
  11. Performance Considerations
  12. Related Documentation

Target: 6,000-9,000 total lines across all 9 files (~670-1000 lines per file) Current: 4,053 lines (4 files) Remaining: ~2,950-4,950 lines (5 files)

"},{"location":"v2/features/map/canvassing/","title":"Canvassing Session System","text":""},{"location":"v2/features/map/canvassing/#overview","title":"Overview","text":"

The canvassing system provides a complete door-to-door organizing workflow with GPS tracking, walking route optimization, visit recording, and progress tracking. It enables volunteers to efficiently canvass assigned territories using mobile devices with real-time location updates.

Key Capabilities:

Use Cases:

"},{"location":"v2/features/map/canvassing/#architecture","title":"Architecture","text":"
graph TD\n    A[Volunteer] -->|Start Session| B[VolunteerMapPage]\n    B -->|POST /api/map/canvass/sessions| C[Canvass Service]\n    C -->|Create| D[(CanvassSession)]\n    C -->|Start GPS| E[Tracking Service]\n    E -->|Create| F[(TrackingSession)]\n\n    B -->|Load Addresses| C\n    C -->|Filter by Cut| G[Spatial Utils]\n    G -->|Point-in-Polygon| H[(Location Model)]\n\n    B -->|Calculate Route| I[Walking Route Service]\n    I -->|Nearest Neighbor| J[Haversine Distance]\n    J -->|Return Route| B\n\n    K[Volunteer GPS] -->|Submit Points| E\n    E -->|Save| L[(TrackPoint)]\n    E -->|Calculate Distance| J\n\n    B -->|Record Visit| C\n    C -->|Create| M[(CanvassVisit)]\n    C -->|Update Address| N[(Address Model)]\n    C -->|Update Progress| D\n\n    O[Admin] -->|View Dashboard| P[CanvassDashboardPage]\n    P -->|GET /api/map/canvass/admin/activity| C\n    C -->|Aggregate Stats| D\n    C -->|Activity Feed| M\n\n    D -->|1:1| F\n    D -->|1:N| M\n    M -->|N:1| N\n\n    style D fill:#e1f5ff\n    style F fill:#e1f5ff\n    style M fill:#e1f5ff\n    style N fill:#e1f5ff\n    style H fill:#e1f5ff

Flow Description:

  1. Volunteer starts session \u2192 Creates CanvassSession + TrackingSession, loads addresses within cut
  2. Calculate route \u2192 Walking route service uses nearest-neighbor from volunteer GPS position
  3. GPS tracking \u2192 Auto-submit points every 10s, calculate distance with haversine
  4. Record visit \u2192 Create CanvassVisit with outcome, update Address support level, update session progress
  5. End session \u2192 Mark session COMPLETED, end tracking session, calculate final stats
  6. Admin oversight \u2192 View active sessions, activity feed, cut progress, volunteer leaderboard
"},{"location":"v2/features/map/canvassing/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/canvassing/#canvasssession-model","title":"CanvassSession Model","text":"

See CanvassSession Model Documentation for full schema.

Key Fields:

Status Lifecycle:

ACTIVE (session running)\n  \u2193 (volunteer ends session)\nCOMPLETED\n\nOR\n\nACTIVE (session running > 12 hours)\n  \u2193 (auto-cleanup cron)\nABANDONED\n
"},{"location":"v2/features/map/canvassing/#canvassvisit-model","title":"CanvassVisit Model","text":"

See CanvassVisit Model Documentation for full schema.

Key Fields:

Visit Outcome Enum:

enum VisitOutcome {\n  NOT_HOME         // Nobody answered door\n  REFUSED          // Refused to speak\n  MOVED            // Resident moved away\n  ALREADY_VOTED    // Already voted (GOTV)\n  SPOKE_WITH       // Had conversation\n  LEFT_LITERATURE  // Left campaign material\n  COME_BACK_LATER  // Asked to return later\n}\n

Related Models:

"},{"location":"v2/features/map/canvassing/#api-endpoints","title":"API Endpoints","text":"

See Canvass Backend Module Documentation for full API reference.

Volunteer Endpoints:

Method Endpoint Auth Description GET /api/map/canvass/volunteer/assignments Any logged-in user Get shifts with cut assignments GET /api/map/canvass/volunteer/stats Any logged-in user Get volunteer canvass statistics GET /api/map/canvass/volunteer/visits Any logged-in user List own canvass visits with pagination POST /api/map/canvass/sessions Any logged-in user Start new canvass session PATCH /api/map/canvass/sessions/:id Any logged-in user Update session (end session) GET /api/map/canvass/sessions/:id/addresses Any logged-in user Get addresses within session cut POST /api/map/canvass/sessions/:id/route Any logged-in user Calculate walking route POST /api/map/canvass/visits Any logged-in user Record single visit POST /api/map/canvass/visits/bulk Any logged-in user Record multiple visits (batch) PATCH /api/map/canvass/volunteer/locations/:id Any logged-in user Update location from canvass

Admin Endpoints:

Method Endpoint Auth Description GET /api/map/canvass/admin/activity MAP_ADMIN Get recent canvass activity feed GET /api/map/canvass/admin/sessions MAP_ADMIN List active canvass sessions GET /api/map/canvass/admin/visits MAP_ADMIN List all canvass visits with filters GET /api/map/canvass/admin/progress MAP_ADMIN Get cut completion progress GET /api/map/canvass/admin/leaderboard MAP_ADMIN Get volunteer visit leaderboard"},{"location":"v2/features/map/canvassing/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/canvassing/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description CANVASS_SESSION_TIMEOUT_HOURS number 12 Auto-abandon active sessions after N hours CANVASS_VISIT_RATE_LIMIT number 30 Max visits per minute per IP"},{"location":"v2/features/map/canvassing/#rate-limiting","title":"Rate Limiting","text":"

Visit Recording Rate Limit:

"},{"location":"v2/features/map/canvassing/#session-auto-cleanup","title":"Session Auto-Cleanup","text":"

Abandoned Session Detection:

System automatically marks sessions as ABANDONED if:

Cleanup Schedule:

// api/src/server.ts\nsetInterval(async () => {\n  await canvassService.cleanupAbandonedSessions();\n}, 60 * 60 * 1000); // 1 hour\n
"},{"location":"v2/features/map/canvassing/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/map/canvassing/#viewing-active-sessions","title":"Viewing Active Sessions","text":"

Step 1: Navigate to Canvass Dashboard

Navigate to Map \u2192 Canvass Dashboard in the admin sidebar.

![CanvassDashboardPage Screenshot Placeholder]

Step 2: View Active Sessions

Active Sessions card displays:

Step 3: View Session Details

Click session row to view:

"},{"location":"v2/features/map/canvassing/#monitoring-canvass-activity","title":"Monitoring Canvass Activity","text":"

Step 1: View Activity Feed

Recent Activity section displays:

Step 2: Filter Activity

Use filters:

Step 3: Export Activity

Click Export CSV to download activity feed for reporting.

"},{"location":"v2/features/map/canvassing/#tracking-cut-completion","title":"Tracking Cut Completion","text":"

Step 1: View Cut Progress

Cut Progress card displays:

Step 2: View Detailed Progress

Click cut row to view:

"},{"location":"v2/features/map/canvassing/#volunteer-leaderboard","title":"Volunteer Leaderboard","text":"

Step 1: View Leaderboard

Leaderboard card displays:

Step 2: Filter by Time Period

Toggle time period:

"},{"location":"v2/features/map/canvassing/#volunteer-workflow","title":"Volunteer Workflow","text":""},{"location":"v2/features/map/canvassing/#starting-a-canvass-session","title":"Starting a Canvass Session","text":"

Step 1: Login

Login at /login with volunteer account (or use TEMP account from shift signup).

Step 2: View Assignments

Navigate to Volunteer \u2192 My Assignments.

Step 3: Select Shift

Click Start Canvass on a shift with cut assignment.

Step 4: Grant GPS Permission

Browser requests geolocation permission. Click Allow.

Step 5: Start Session

System redirects to /volunteer/canvass/:cutId (full-screen map).

System will:

  1. Create CanvassSession (status=ACTIVE)
  2. Create TrackingSession (linked 1:1)
  3. Load addresses within cut polygon
  4. Calculate walking route from current GPS position
  5. Start GPS auto-tracking (submit points every 10s)
"},{"location":"v2/features/map/canvassing/#following-walking-route","title":"Following Walking Route","text":"

Step 1: View Route on Map

Map displays:

Step 2: Navigate to First Address

Follow route to nearest unvisited address. Route recalculates when you move.

Step 3: View Address Details

Tap marker to view:

"},{"location":"v2/features/map/canvassing/#recording-a-visit","title":"Recording a Visit","text":"

Step 1: Knock on Door

Approach address and knock/ring doorbell.

Step 2: Open Visit Recording Form

Tap Record Visit button in bottom toolbar. Bottom sheet slides up.

Step 3: Select Outcome

Choose visit outcome:

Step 4: Update Support Level (if applicable)

For \"Spoke With\" outcome, select support level:

Step 5: Sign Request (optional)

Toggle Sign Requested if resident wants lawn/window sign.

Step 6: Add Notes (optional)

Enter free-text notes (e.g., \"Asked about healthcare policy\", \"Concerned about taxes\").

Step 7: Save Visit

Tap Save Visit. System will:

  1. Create CanvassVisit record with outcome + timestamp
  2. Update Address with new support level + sign status + notes
  3. Increment session.totalVisits count
  4. Update cut.completionPercentage
  5. Create LocationHistory audit record
  6. Submit GPS trackpoint with eventType=VISIT_RECORDED
  7. Update marker to green (visited)
  8. Recalculate walking route (exclude visited address)
"},{"location":"v2/features/map/canvassing/#ending-a-canvass-session","title":"Ending a Canvass Session","text":"

Step 1: Finish Route

Complete visits for all addresses (or as many as possible).

Step 2: End Session

Tap End Session button in header.

Step 3: Confirm

Confirmation modal displays session summary:

Step 4: Submit

Tap End Session. System will:

  1. Update CanvassSession (status=COMPLETED, endedAt=now)
  2. End TrackingSession (isActive=false, endedAt=now)
  3. Calculate final stats (totalVisits, totalDistanceM)
  4. Redirect to /volunteer/activity (visit history page)
"},{"location":"v2/features/map/canvassing/#viewing-visit-history","title":"Viewing Visit History","text":"

Step 1: Navigate to My Activity

Navigate to Volunteer \u2192 My Activity.

Step 2: View Visit List

Table displays:

Step 3: Filter Visits

Use filters:

Step 4: View Session History

Navigate to My Routes to view:

"},{"location":"v2/features/map/canvassing/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/canvassing/#start-canvass-session-backend","title":"Start Canvass Session (Backend)","text":"
// api/src/modules/map/canvass/canvass.service.ts\nasync startSession(userId: string, data: StartSessionInput) {\n  const { cutId, shiftId, startLat, startLng } = data;\n\n  // Check for existing active session\n  const existing = await prisma.canvassSession.findFirst({\n    where: { userId, status: CanvassSessionStatus.ACTIVE },\n  });\n\n  if (existing) {\n    throw new AppError(400, 'Already have an active session', 'SESSION_ACTIVE');\n  }\n\n  // Create session + tracking session in transaction\n  const session = await prisma.$transaction(async (tx) => {\n    const canvassSession = await tx.canvassSession.create({\n      data: {\n        userId,\n        cutId,\n        shiftId,\n        status: CanvassSessionStatus.ACTIVE,\n      },\n    });\n\n    if (startLat && startLng) {\n      await tx.trackingSession.create({\n        data: {\n          userId,\n          canvassSessionId: canvassSession.id,\n          lastLatitude: new Prisma.Decimal(startLat),\n          lastLongitude: new Prisma.Decimal(startLng),\n          lastRecordedAt: new Date(),\n        },\n      });\n    }\n\n    return canvassSession;\n  });\n\n  setActiveCanvassSessions(\n    await prisma.canvassSession.count({\n      where: { status: CanvassSessionStatus.ACTIVE },\n    })\n  );\n\n  return session;\n}\n
"},{"location":"v2/features/map/canvassing/#calculate-walking-route-backend","title":"Calculate Walking Route (Backend)","text":"
// api/src/modules/map/canvass/canvass-route.service.ts\nexport function calculateWalkingRoute(\n  locations: RouteLocation[],\n  startLat?: number,\n  startLng?: number,\n  cutGeojson?: string,\n): RouteResult {\n  if (locations.length === 0) {\n    return { orderedLocations: [], totalDistanceMeters: 0, estimatedMinutes: 0 };\n  }\n\n  // Determine starting point\n  let currentLat: number;\n  let currentLng: number;\n\n  if (startLat !== undefined && startLng !== undefined) {\n    currentLat = startLat;\n    currentLng = startLng;\n  } else if (cutGeojson) {\n    const polygons = parseGeoJsonPolygon(cutGeojson);\n    const centroid = calculateCentroid(polygons[0]!);\n    currentLat = centroid.lat;\n    currentLng = centroid.lng;\n  } else {\n    // Use first location as starting point\n    currentLat = locations[0]!.latitude;\n    currentLng = locations[0]!.longitude;\n  }\n\n  const remaining = [...locations];\n  const ordered: RouteLocation[] = [];\n  let totalDistance = 0;\n\n  // Nearest-neighbor algorithm\n  while (remaining.length > 0) {\n    let nearestIdx = 0;\n    let nearestDist = Infinity;\n\n    for (let i = 0; i < remaining.length; i++) {\n      const loc = remaining[i]!;\n      const dist = haversineDistance(currentLat, currentLng, loc.latitude, loc.longitude);\n      if (dist < nearestDist) {\n        nearestDist = dist;\n        nearestIdx = i;\n      }\n    }\n\n    const nearest = remaining.splice(nearestIdx, 1)[0]!;\n    ordered.push(nearest);\n    totalDistance += nearestDist;\n    currentLat = nearest.latitude;\n    currentLng = nearest.longitude;\n  }\n\n  const WALKING_SPEED_MPS = 5000 / 60; // 5 km/h in m/min\n  const MINUTES_PER_DOOR = 2;\n  const walkingMinutes = totalDistance / WALKING_SPEED_MPS;\n  const doorMinutes = ordered.length * MINUTES_PER_DOOR;\n  const estimatedMinutes = Math.round(walkingMinutes + doorMinutes);\n\n  return {\n    orderedLocations: ordered,\n    totalDistanceMeters: Math.round(totalDistance),\n    estimatedMinutes,\n  };\n}\n
"},{"location":"v2/features/map/canvassing/#record-visit-backend","title":"Record Visit (Backend)","text":"
// api/src/modules/map/canvass/canvass.service.ts\nasync recordVisit(userId: string, data: RecordVisitInput) {\n  const { sessionId, addressId, outcome, supportLevel, signRequested, notes } = data;\n\n  // Verify session ownership and active status\n  const session = await prisma.canvassSession.findFirst({\n    where: { id: sessionId, userId, status: CanvassSessionStatus.ACTIVE },\n  });\n\n  if (!session) {\n    throw new AppError(404, 'Active session not found', 'SESSION_NOT_FOUND');\n  }\n\n  // Create visit + update address in transaction\n  const visit = await prisma.$transaction(async (tx) => {\n    const canvassVisit = await tx.canvassVisit.create({\n      data: {\n        sessionId,\n        userId,\n        addressId,\n        outcome,\n        supportLevel,\n        signRequested: signRequested ?? false,\n        notes,\n      },\n    });\n\n    // Update address with new data\n    if (supportLevel || signRequested !== undefined || notes) {\n      await tx.address.update({\n        where: { id: addressId },\n        data: {\n          ...(supportLevel && { supportLevel }),\n          ...(signRequested !== undefined && { sign: signRequested }),\n          ...(notes && { notes }),\n        },\n      });\n    }\n\n    // Increment session visit count\n    await tx.canvassSession.update({\n      where: { id: sessionId },\n      data: { totalVisits: { increment: 1 } },\n    });\n\n    // Update cut completion percentage\n    if (session.cutId) {\n      const cutId = session.cutId;\n      const totalAddresses = await tx.address.count({\n        where: {\n          location: {\n            // Point-in-polygon query omitted for brevity\n          },\n        },\n      });\n\n      const visitedAddresses = await tx.canvassVisit.count({\n        where: {\n          session: { cutId },\n        },\n      });\n\n      const completionPercentage = Math.round((visitedAddresses / totalAddresses) * 100);\n\n      await tx.cut.update({\n        where: { id: cutId },\n        data: { completionPercentage },\n      });\n    }\n\n    return canvassVisit;\n  });\n\n  recordCanvassVisit(outcome);\n\n  return visit;\n}\n
"},{"location":"v2/features/map/canvassing/#gps-auto-tracking-frontend","title":"GPS Auto-Tracking (Frontend)","text":"
// admin/src/components/canvass/GPSTracker.tsx\nuseEffect(() => {\n  if (!session || !geolocationEnabled) return;\n\n  const watchId = navigator.geolocation.watchPosition(\n    (position) => {\n      const point = {\n        latitude: position.coords.latitude,\n        longitude: position.coords.longitude,\n        accuracy: position.coords.accuracy,\n        recordedAt: new Date().toISOString(),\n      };\n\n      // Add to local buffer\n      setPointsBuffer((prev) => [...prev, point]);\n\n      // Update current position\n      setCurrentPosition([point.latitude, point.longitude]);\n    },\n    (error) => {\n      console.error('GPS error:', error);\n      message.error('GPS tracking failed');\n    },\n    {\n      enableHighAccuracy: true,\n      maximumAge: 0,\n      timeout: 10000,\n    }\n  );\n\n  // Submit buffered points every 10 seconds\n  const interval = setInterval(async () => {\n    if (pointsBuffer.length === 0) return;\n\n    try {\n      await api.post(`/map/tracking/sessions/${trackingSessionId}/points`, {\n        points: pointsBuffer,\n      });\n\n      setPointsBuffer([]);\n    } catch (error) {\n      console.error('Failed to submit GPS points:', error);\n    }\n  }, 10000);\n\n  return () => {\n    navigator.geolocation.clearWatch(watchId);\n    clearInterval(interval);\n  };\n}, [session, geolocationEnabled, pointsBuffer, trackingSessionId]);\n
"},{"location":"v2/features/map/canvassing/#visit-recording-form-frontend","title":"Visit Recording Form (Frontend)","text":"
// admin/src/components/canvass/VisitRecordingForm.tsx\nconst handleSubmit = async (values: any) => {\n  try {\n    await api.post('/map/canvass/visits', {\n      sessionId: session.id,\n      addressId: selectedAddress.id,\n      outcome: values.outcome,\n      supportLevel: values.supportLevel,\n      signRequested: values.signRequested,\n      notes: values.notes,\n    });\n\n    message.success('Visit recorded');\n    form.resetFields();\n    onVisitRecorded();\n  } catch (error) {\n    message.error('Failed to record visit');\n  }\n};\n
"},{"location":"v2/features/map/canvassing/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/canvassing/#issue-walking-route-not-optimal","title":"Issue: Walking Route Not Optimal","text":"

Symptoms:

Causes:

Solutions:

  1. Use volunteer GPS position as start:
// Always pass volunteer GPS position to route calculation\nconst route = await calculateWalkingRoute(\n  locations,\n  currentLat,\n  currentLng,\n  cut.geojson\n);\n
  1. Consider alternative algorithms:

For better optimization, use 2-opt or genetic algorithms (computationally expensive):

// Install optimization library\nnpm install routing-js\n\n// Use 2-opt algorithm\nimport { twoOpt } from 'routing-js';\nconst optimized = twoOpt(locations, distanceMatrix);\n
  1. Pre-optimize routes for shifts:

Admin can pre-calculate optimal routes and assign to volunteers:

// Calculate route once, store in Shift model\nconst route = await calculateWalkingRoute(locations);\nawait prisma.shift.update({\n  where: { id: shiftId },\n  data: { preCalculatedRoute: JSON.stringify(route) },\n});\n
"},{"location":"v2/features/map/canvassing/#issue-session-auto-abandoned-prematurely","title":"Issue: Session Auto-Abandoned Prematurely","text":"

Symptoms:

Causes:

Solutions:

  1. Increase timeout:
# In .env\nCANVASS_SESSION_TIMEOUT_HOURS=24  # Was 12, increase to 24\n
  1. Record \"heartbeat\" visits:

Add periodic \"still active\" ping to prevent timeout:

// Volunteer app sends heartbeat every 30 minutes\nsetInterval(async () => {\n  await api.post(`/map/canvass/sessions/${sessionId}/heartbeat`);\n}, 30 * 60 * 1000);\n
  1. Allow session resumption:

Let volunteers resume ABANDONED sessions:

// Backend: Add resume endpoint\nasync resumeSession(userId: string, sessionId: string) {\n  const session = await prisma.canvassSession.findFirst({\n    where: { id: sessionId, userId, status: CanvassSessionStatus.ABANDONED },\n  });\n\n  if (!session) {\n    throw new AppError(404, 'Abandoned session not found', 'SESSION_NOT_FOUND');\n  }\n\n  return prisma.canvassSession.update({\n    where: { id: sessionId },\n    data: { status: CanvassSessionStatus.ACTIVE },\n  });\n}\n
"},{"location":"v2/features/map/canvassing/#issue-gps-tracking-draining-battery","title":"Issue: GPS Tracking Draining Battery","text":"

Symptoms:

Causes:

Solutions:

  1. Reduce GPS accuracy:
navigator.geolocation.watchPosition(\n  callback,\n  errorCallback,\n  {\n    enableHighAccuracy: false, // Use WiFi/cellular only (less accurate but lower power)\n    maximumAge: 5000,          // Cache position for 5s\n    timeout: 30000,            // Longer timeout\n  }\n);\n
  1. Reduce submission frequency:
// Submit GPS points every 30s instead of 10s\nconst SUBMIT_INTERVAL_MS = 30000; // Was 10000\n
  1. Pause tracking during breaks:

Add \"Pause Tracking\" button to stop GPS watchPosition:

const pauseTracking = () => {\n  navigator.geolocation.clearWatch(watchId);\n  setTrackingPaused(true);\n};\n\nconst resumeTracking = () => {\n  // Start watchPosition again\n  setTrackingPaused(false);\n};\n
"},{"location":"v2/features/map/canvassing/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/canvassing/#visit-recording-rate-limiting","title":"Visit Recording Rate Limiting","text":"

Prevent Abuse:

Rate limit prevents accidental bulk submissions:

// api/src/middleware/rate-limit.ts\nconst canvassVisitLimiter = new RateLimiterRedis({\n  storeClient: redis,\n  keyPrefix: 'rl:canvass-visit:',\n  points: 30,      // 30 visits\n  duration: 60,    // per 60 seconds\n  blockDuration: 300, // block for 5 minutes if exceeded\n});\n

Legitimate Use Cases:

"},{"location":"v2/features/map/canvassing/#session-cleanup-performance","title":"Session Cleanup Performance","text":"

Efficient Abandoned Session Query:

-- Index for abandoned session cleanup\nCREATE INDEX idx_canvass_sessions_abandoned ON \"CanvassSession\" (\"status\", \"startedAt\")\nWHERE status = 'ACTIVE';\n\n-- Efficient query\nSELECT id FROM \"CanvassSession\"\nWHERE status = 'ACTIVE'\n  AND \"startedAt\" < NOW() - INTERVAL '12 hours';\n
"},{"location":"v2/features/map/canvassing/#cut-completion-calculation","title":"Cut Completion Calculation","text":"

Avoid N+1 Queries:

// Inefficient: query per cut\nfor (const cut of cuts) {\n  const visited = await prisma.canvassVisit.count({\n    where: { session: { cutId: cut.id } },\n  });\n  const total = await getAddressesInCut(cut.id).length;\n  cut.completionPercentage = (visited / total) * 100;\n}\n\n// Efficient: single aggregation query\nconst completionStats = await prisma.canvassSession.groupBy({\n  by: ['cutId'],\n  where: { status: CanvassSessionStatus.COMPLETED },\n  _count: { visits: true },\n});\n
"},{"location":"v2/features/map/canvassing/#related-documentation","title":"Related Documentation","text":"

Backend Modules:

Frontend Pages:

Database:

Features:

"},{"location":"v2/features/map/cuts/","title":"Geographic Polygon Overlays (Cuts)","text":""},{"location":"v2/features/map/cuts/#overview","title":"Overview","text":"

The cuts system provides polygon-based geographic organizing using customizable map overlays. Cuts enable campaigns to divide territories into canvassing zones, track completion progress, and assign volunteers to specific areas.

Key Capabilities:

Use Cases:

"},{"location":"v2/features/map/cuts/#architecture","title":"Architecture","text":"
graph TD\n    A[Admin User] -->|Draws Polygon| B[CutDrawingMode]\n    B -->|Click Vertices| C[Leaflet Map]\n    C -->|Auto-Close Detection| D[GeoJSON Polygon]\n    D -->|POST /api/map/cuts| E[Cuts Service]\n    E -->|Calculate Bounds| F[Spatial Utils]\n    F -->|Save| G[(Cut Model)]\n\n    H[Public Map] -->|Load Cuts| I[GET /api/public/map/cuts]\n    I -->|Return GeoJSON| E\n    E -->|Query| G\n    I -->|Render| J[CutOverlays Component]\n\n    K[Canvass Session] -->|Start in Cut| L[Canvass Service]\n    L -->|Load Addresses| M[Locations Service]\n    M -->|Point-in-Polygon| F\n    F -->|Filter| N[(Location Model)]\n\n    O[Shift] -->|Assigned to Cut| G\n    G -->|1:N| O\n\n    P[Export Locations] -->|Filter by Cut| M\n    M -->|Query Polygon| F\n\n    style G fill:#e1f5ff\n    style N fill:#e1f5ff\n    style O fill:#e1f5ff

Flow Description:

  1. Admin draws cut \u2192 Click vertices on map, auto-close detection, generate GeoJSON
  2. Save cut \u2192 Calculate bounds from coordinates, store polygon in database
  3. Public map loads \u2192 Query public cuts, render as colored overlays with opacity
  4. Canvass session starts \u2192 Load addresses within cut polygon using ray-casting
  5. Shift assignment \u2192 Link shift to cut for volunteer scheduling
  6. Export locations \u2192 Filter by cut polygon to generate walk sheet
"},{"location":"v2/features/map/cuts/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/cuts/#cut-model","title":"Cut Model","text":"

See Cut Model Documentation for full schema.

Key Fields:

GeoJSON Format:

{\n  \"type\": \"Polygon\",\n  \"coordinates\": [\n    [\n      [-75.6972, 45.4215],\n      [-75.6980, 45.4220],\n      [-75.6960, 45.4230],\n      [-75.6950, 45.4225],\n      [-75.6972, 45.4215]\n    ]\n  ]\n}\n

Bounds Format:

{\n  \"minLat\": 45.4215,\n  \"maxLat\": 45.4230,\n  \"minLng\": -75.6980,\n  \"maxLng\": -75.6950\n}\n

Related Models:

"},{"location":"v2/features/map/cuts/#api-endpoints","title":"API Endpoints","text":"

See Cuts Backend Module Documentation for full API reference.

Admin Endpoints:

Method Endpoint Auth Description GET /api/map/cuts MAP_ADMIN List cuts with pagination, search, category filter GET /api/map/cuts/stats MAP_ADMIN Get cut statistics (total, by category) GET /api/map/cuts/:id MAP_ADMIN Get cut details POST /api/map/cuts MAP_ADMIN Create new cut with polygon PATCH /api/map/cuts/:id MAP_ADMIN Update cut DELETE /api/map/cuts/:id MAP_ADMIN Delete cut (blocked if isOfficial=true) GET /api/map/cuts/:id/locations MAP_ADMIN Get locations within cut polygon GET /api/map/cuts/:id/progress MAP_ADMIN Get canvassing progress for cut

Public Endpoints:

Method Endpoint Auth Description GET /api/public/map/cuts None List public cuts (isPublic=true) GET /api/public/map/cuts/:id None Get public cut details"},{"location":"v2/features/map/cuts/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/cuts/#environment-variables","title":"Environment Variables","text":"

No specific environment variables for cuts. Uses standard database and map settings.

"},{"location":"v2/features/map/cuts/#cut-category-enum","title":"Cut Category Enum","text":"
enum CutCategory {\n  CUSTOM       // User-defined boundary\n  WARD         // Municipal ward boundary\n  NEIGHBORHOOD // Neighborhood association boundary\n  DISTRICT     // Electoral district boundary\n}\n
"},{"location":"v2/features/map/cuts/#default-values","title":"Default Values","text":"Field Default Description color #3498db Blue color for overlay opacity 0.3 30% opacity (transparent) isPublic false Hidden from public map isOfficial false Can be deleted by admin showLocations true Show location markers within cut exportEnabled true Allow walk sheet export completionPercentage 0 Auto-updated by canvass service"},{"location":"v2/features/map/cuts/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/map/cuts/#creating-a-cut","title":"Creating a Cut","text":"

Step 1: Navigate to Cuts Page

Navigate to Map \u2192 Cuts in the admin sidebar.

![CutsPage Screenshot Placeholder]

Step 2: Open Drawing Tab

Click Drawing tab to switch to map drawing mode.

Step 3: Activate Drawing Mode

Click Draw Cut button in the map controls. Map cursor changes to crosshair.

Step 4: Click Vertices

Click on the map to place polygon vertices:

Step 5: Configure Cut

Fill in the cut form (right sidebar):

Step 6: Save Cut

Click Save Cut. The system will:

  1. Generate GeoJSON from vertices
  2. Calculate bounding box
  3. Save to database
  4. Render polygon on map with configured color/opacity
"},{"location":"v2/features/map/cuts/#editing-a-cut","title":"Editing a Cut","text":"

Step 1: Select Cut

On Table tab, click Edit button for a cut.

Step 2: Update Fields

Modify cut properties:

Step 3: Re-Draw Polygon (Optional)

To change polygon shape:

  1. Switch to Drawing tab
  2. Click Edit Cut button
  3. Delete old vertices (click vertices to remove)
  4. Add new vertices
  5. Auto-close polygon

Step 4: Save Changes

Click Update to save changes. Bounds are auto-recalculated if polygon changed.

"},{"location":"v2/features/map/cuts/#viewing-locations-in-cut","title":"Viewing Locations in Cut","text":"

Step 1: Select Cut

Click cut row in table to select.

Step 2: Click \"View Locations\"

Click View Locations button.

Step 3: View Filtered Table

System displays locations within cut polygon:

Step 4: Export Locations

Click Export CSV to download locations for walk sheet generation.

"},{"location":"v2/features/map/cuts/#assigning-cut-to-shift","title":"Assigning Cut to Shift","text":"

Step 1: Create/Edit Shift

On Map \u2192 Shifts page, create or edit a shift.

Step 2: Select Cut

In shift form, choose cut from Cut dropdown.

Step 3: Save Shift

Shift is now linked to cut. Volunteers will see cut name on shift details.

"},{"location":"v2/features/map/cuts/#tracking-cut-completion","title":"Tracking Cut Completion","text":"

Step 1: View Cut Progress

On CutsPage, click Progress button for a cut.

Step 2: View Metrics

System displays:

Step 3: View Canvass Activity

Table shows recent canvass visits within cut:

"},{"location":"v2/features/map/cuts/#public-workflow","title":"Public Workflow","text":"

Public users can view cut overlays on the interactive map.

Step 1: Navigate to Public Map

Visit /map (no authentication required).

Step 2: Toggle Cut Overlays

Click Cuts button in map controls to open overlay panel.

Step 3: Select Cuts

Check/uncheck cuts to show/hide on map:

Step 4: View Cut Details

Click on a cut polygon to view:

"},{"location":"v2/features/map/cuts/#volunteer-workflow","title":"Volunteer Workflow","text":"

Volunteers interact with cuts via shift assignments.

Step 1: View Assigned Shifts

On Volunteer \u2192 My Assignments page, view shifts with cut assignments.

Step 2: Start Canvass Session

Click Start Canvass on a shift. Redirects to /volunteer/canvass/:cutId.

Step 3: View Cut on Map

Full-screen map shows:

See Canvassing Documentation for full volunteer workflow.

"},{"location":"v2/features/map/cuts/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/cuts/#cut-service-create-backend","title":"Cut Service Create (Backend)","text":"
// api/src/modules/map/cuts/cuts.service.ts\nimport { parseGeoJsonPolygon, calculateBounds } from '../../../utils/spatial';\n\nasync create(data: CreateCutInput, userId: string) {\n  // Auto-calculate bounds from geojson if not provided\n  let boundsStr = data.bounds;\n  if (!boundsStr) {\n    try {\n      const rings = parseGeoJsonPolygon(data.geojson);\n      const allCoords = rings.flat();\n      const bounds = calculateBounds(allCoords);\n      boundsStr = JSON.stringify(bounds);\n    } catch {\n      // Bounds calculation optional\n    }\n  }\n\n  const cut = await prisma.cut.create({\n    data: {\n      name: data.name,\n      description: data.description,\n      color: data.color,\n      opacity: data.opacity,\n      category: data.category,\n      isPublic: data.isPublic,\n      isOfficial: data.isOfficial,\n      geojson: data.geojson,\n      bounds: boundsStr,\n      showLocations: data.showLocations,\n      exportEnabled: data.exportEnabled,\n      assignedTo: data.assignedTo,\n      createdByUserId: userId,\n    },\n  });\n\n  return cut;\n}\n
"},{"location":"v2/features/map/cuts/#bounds-calculation-backend","title":"Bounds Calculation (Backend)","text":"
// api/src/utils/spatial.ts\nexport function calculateBounds(coordinates: number[][]): {\n  minLat: number;\n  maxLat: number;\n  minLng: number;\n  maxLng: number;\n} {\n  let minLat = Infinity;\n  let maxLat = -Infinity;\n  let minLng = Infinity;\n  let maxLng = -Infinity;\n\n  for (const coord of coordinates) {\n    const lng = coord[0]!;\n    const lat = coord[1]!;\n    if (lat < minLat) minLat = lat;\n    if (lat > maxLat) maxLat = lat;\n    if (lng < minLng) minLng = lng;\n    if (lng > maxLng) maxLng = lng;\n  }\n\n  return { minLat, maxLat, minLng, maxLng };\n}\n
"},{"location":"v2/features/map/cuts/#point-in-polygon-filter-backend","title":"Point-in-Polygon Filter (Backend)","text":"
// api/src/modules/map/cuts/cuts.service.ts\nimport { isPointInPolygon, parseGeoJsonPolygon } from '../../../utils/spatial';\n\nasync getLocationsInCut(cutId: string) {\n  const cut = await prisma.cut.findUnique({\n    where: { id: cutId },\n    select: { geojson: true },\n  });\n\n  if (!cut?.geojson) {\n    throw new AppError(404, 'Cut not found', 'CUT_NOT_FOUND');\n  }\n\n  // Get all locations (or use bounds for optimization)\n  const locations = await prisma.location.findMany({\n    select: {\n      id: true,\n      latitude: true,\n      longitude: true,\n      address: true,\n    },\n  });\n\n  // Parse polygon coordinates\n  const polygons = parseGeoJsonPolygon(cut.geojson);\n\n  // Filter locations using ray-casting algorithm\n  const filtered = locations.filter((loc) => {\n    const lat = Number(loc.latitude);\n    const lng = Number(loc.longitude);\n    return polygons.some((poly) => isPointInPolygon(lat, lng, poly));\n  });\n\n  return filtered;\n}\n
"},{"location":"v2/features/map/cuts/#ray-casting-algorithm-backend","title":"Ray-Casting Algorithm (Backend)","text":"
// api/src/utils/spatial.ts\nexport function isPointInPolygon(\n  lat: number,\n  lng: number,\n  polygonCoords: number[][]\n): boolean {\n  let inside = false;\n  for (let i = 0, j = polygonCoords.length - 1; i < polygonCoords.length; j = i++) {\n    const xi = polygonCoords[i]![1]!; // lat\n    const yi = polygonCoords[i]![0]!; // lng\n    const xj = polygonCoords[j]![1]!;\n    const yj = polygonCoords[j]![0]!;\n\n    const intersect = ((yi > lng) !== (yj > lng)) &&\n      (lat < (xj - xi) * (lng - yi) / (yj - yi) + xi);\n    if (intersect) inside = !inside;\n  }\n  return inside;\n}\n
"},{"location":"v2/features/map/cuts/#cut-drawing-mode-frontend","title":"Cut Drawing Mode (Frontend)","text":"
// admin/src/components/map/CutDrawingMode.tsx\nimport { useState, useEffect } from 'react';\nimport { useMapEvents } from 'react-leaflet';\nimport type { LatLng } from 'leaflet';\n\ninterface CutDrawingModeProps {\n  onPolygonComplete: (vertices: LatLng[]) => void;\n}\n\nexport default function CutDrawingMode({ onPolygonComplete }: CutDrawingModeProps) {\n  const [vertices, setVertices] = useState<LatLng[]>([]);\n  const [isDrawing, setIsDrawing] = useState(true);\n\n  useMapEvents({\n    click(e) {\n      if (!isDrawing) return;\n\n      const newVertex = e.latlng;\n\n      // Auto-close detection: if click near first vertex (within 10px)\n      if (vertices.length >= 3) {\n        const firstVertex = vertices[0]!;\n        const map = e.target;\n        const firstPoint = map.latLngToContainerPoint(firstVertex);\n        const newPoint = map.latLngToContainerPoint(newVertex);\n        const distance = Math.sqrt(\n          Math.pow(firstPoint.x - newPoint.x, 2) +\n          Math.pow(firstPoint.y - newPoint.y, 2)\n        );\n\n        if (distance < 10) {\n          // Auto-close polygon\n          setIsDrawing(false);\n          onPolygonComplete(vertices);\n          return;\n        }\n      }\n\n      // Add vertex\n      setVertices([...vertices, newVertex]);\n    },\n  });\n\n  return (\n    <>\n      {/* Render temporary polygon while drawing */}\n      {vertices.length >= 2 && (\n        <Polygon positions={vertices} pathOptions={{ color: '#3498db', opacity: 0.5 }} />\n      )}\n      {/* Render vertex markers */}\n      {vertices.map((v, i) => (\n        <CircleMarker\n          key={i}\n          center={v}\n          radius={5}\n          pathOptions={{ color: '#e74c3c', fillColor: '#e74c3c', fillOpacity: 1 }}\n        />\n      ))}\n    </>\n  );\n}\n
"},{"location":"v2/features/map/cuts/#cut-overlays-rendering-frontend","title":"Cut Overlays Rendering (Frontend)","text":"
// admin/src/components/map/CutOverlays.tsx\nimport { Polygon, Popup } from 'react-leaflet';\nimport type { Cut } from '@/types/api';\n\ninterface CutOverlaysProps {\n  cuts: Cut[];\n  visibleCutIds: string[];\n}\n\nexport default function CutOverlays({ cuts, visibleCutIds }: CutOverlaysProps) {\n  return (\n    <>\n      {cuts\n        .filter((cut) => visibleCutIds.includes(cut.id))\n        .map((cut) => {\n          const geojson = JSON.parse(cut.geojson);\n          // GeoJSON uses [lng, lat], Leaflet uses [lat, lng]\n          const positions = geojson.coordinates[0].map(([lng, lat]: number[]) => [lat, lng]);\n\n          return (\n            <Polygon\n              key={cut.id}\n              positions={positions}\n              pathOptions={{\n                color: cut.color,\n                fillColor: cut.color,\n                fillOpacity: cut.opacity,\n                weight: 2,\n              }}\n            >\n              <Popup>\n                <div>\n                  <strong>{cut.name}</strong>\n                  <br />\n                  {cut.category}\n                  {cut.assignedTo && (\n                    <>\n                      <br />\n                      Assigned to: {cut.assignedTo}\n                    </>\n                  )}\n                </div>\n              </Popup>\n            </Polygon>\n          );\n        })}\n    </>\n  );\n}\n
"},{"location":"v2/features/map/cuts/#convert-leaflet-polygon-to-geojson-frontend","title":"Convert Leaflet Polygon to GeoJSON (Frontend)","text":"
// admin/src/pages/CutsPage.tsx\nconst handleSaveCut = async (vertices: LatLng[]) => {\n  // Convert Leaflet [lat, lng] to GeoJSON [lng, lat]\n  const coordinates = vertices.map((v) => [v.lng, v.lat]);\n\n  // Close polygon (first vertex === last vertex)\n  coordinates.push(coordinates[0]!);\n\n  const geojson = {\n    type: 'Polygon',\n    coordinates: [coordinates],\n  };\n\n  try {\n    const { data } = await api.post<Cut>('/map/cuts', {\n      name: cutName,\n      description: cutDescription,\n      geojson: JSON.stringify(geojson),\n      color: cutColor,\n      opacity: cutOpacity,\n      category: cutCategory,\n      isPublic: isPublic,\n      isOfficial: isOfficial,\n    });\n\n    message.success('Cut created');\n    fetchCuts();\n  } catch (error) {\n    message.error('Failed to create cut');\n  }\n};\n
"},{"location":"v2/features/map/cuts/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/cuts/#issue-polygon-not-closing","title":"Issue: Polygon Not Closing","text":"

Symptoms:

Causes:

Solutions:

  1. Increase auto-close threshold:
// admin/src/components/map/CutDrawingMode.tsx\nconst AUTO_CLOSE_DISTANCE_PX = 15; // Was 10, increase to 15\n\nif (distance < AUTO_CLOSE_DISTANCE_PX) {\n  // Auto-close polygon\n}\n
  1. Manual close button:

Add explicit \"Close Polygon\" button for mobile users:

<Button onClick={() => {\n  if (vertices.length >= 3) {\n    onPolygonComplete(vertices);\n  }\n}}>\n  Close Polygon\n</Button>\n
"},{"location":"v2/features/map/cuts/#issue-point-in-polygon-returns-wrong-results","title":"Issue: Point-in-Polygon Returns Wrong Results","text":"

Symptoms:

Causes:

Solutions:

  1. Verify coordinate order:
// GeoJSON uses [lng, lat]\nconst geojson = {\n  type: 'Polygon',\n  coordinates: [\n    [\n      [-75.6972, 45.4215], // [lng, lat]\n      [-75.6980, 45.4220],\n      // ...\n    ]\n  ]\n};\n\n// Leaflet uses [lat, lng]\n<Polygon positions={[[45.4215, -75.6972], [45.4220, -75.6980]]} />\n
  1. Verify polygon closure:
-- Check if polygon is properly closed\nSELECT id, name,\n  geojson::json->'coordinates'->0->0 as first_vertex,\n  geojson::json->'coordinates'->0->-1 as last_vertex\nFROM \"Cut\"\nWHERE id = 'YOUR_CUT_ID';\n\n-- First and last should be identical\n
  1. Test with known points:
# Test point-in-polygon directly\ncurl -X POST http://localhost:4000/api/map/cuts/YOUR_CUT_ID/test-point \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"latitude\":45.4220,\"longitude\":-75.6975}'\n
"},{"location":"v2/features/map/cuts/#issue-cut-rendering-performance-slow","title":"Issue: Cut Rendering Performance Slow","text":"

Symptoms:

Causes:

Solutions:

  1. Simplify complex polygons:

Use Turf.js simplify algorithm to reduce vertices:

import * as turf from '@turf/turf';\n\nconst simplified = turf.simplify(polygon, {\n  tolerance: 0.0001, // Adjust based on zoom level\n  highQuality: true\n});\n
  1. Lazy render cuts:

Only render cuts within current map bounds:

const visibleCuts = cuts.filter((cut) => {\n  const bounds = JSON.parse(cut.bounds);\n  const mapBounds = map.getBounds();\n  return mapBounds.intersects([\n    [bounds.minLat, bounds.minLng],\n    [bounds.maxLat, bounds.maxLng]\n  ]);\n});\n
  1. Use Canvas renderer:

For large polygons, use Leaflet Canvas renderer instead of SVG:

<Polygon\n  positions={positions}\n  renderer={L.canvas()}\n  pathOptions={{ color: cut.color }}\n/>\n
"},{"location":"v2/features/map/cuts/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/cuts/#spatial-query-optimization","title":"Spatial Query Optimization","text":"

Bounds Pre-Filter:

Always pre-filter by bounding box before point-in-polygon:

async getLocationsInCut(cutId: string) {\n  const cut = await prisma.cut.findUnique({ where: { id: cutId } });\n  const bounds = JSON.parse(cut.bounds);\n\n  // Pre-filter by bounds (fast, uses index)\n  const candidates = await prisma.location.findMany({\n    where: {\n      latitude: {\n        gte: new Prisma.Decimal(bounds.minLat),\n        lte: new Prisma.Decimal(bounds.maxLat),\n      },\n      longitude: {\n        gte: new Prisma.Decimal(bounds.minLng),\n        lte: new Prisma.Decimal(bounds.maxLng),\n      },\n    },\n  });\n\n  // Then apply point-in-polygon (slower, but fewer candidates)\n  const polygons = parseGeoJsonPolygon(cut.geojson);\n  return candidates.filter((loc) => {\n    const lat = Number(loc.latitude);\n    const lng = Number(loc.longitude);\n    return polygons.some((poly) => isPointInPolygon(lat, lng, poly));\n  });\n}\n

Performance Impact:

"},{"location":"v2/features/map/cuts/#polygon-simplification","title":"Polygon Simplification","text":"

Reduce Vertices for Large Cuts:

Use Douglas-Peucker algorithm to simplify polygons while preserving shape:

import * as turf from '@turf/turf';\n\nfunction simplifyPolygon(geojson: string, tolerance: number = 0.0001): string {\n  const polygon = JSON.parse(geojson);\n  const simplified = turf.simplify(polygon, { tolerance, highQuality: true });\n  return JSON.stringify(simplified);\n}\n\n// Usage: simplify when importing official boundaries (e.g., electoral districts)\nconst simplifiedGeojson = simplifyPolygon(officialBoundary, 0.0005);\n

Tolerance Guidelines:

"},{"location":"v2/features/map/cuts/#caching-cut-queries","title":"Caching Cut Queries","text":"

Cache Frequently Used Cuts:

// Cache cut polygons in Redis for fast repeated queries\nconst CACHE_KEY = `CUT_POLYGON:${cutId}`;\nconst cached = await redis.get(CACHE_KEY);\n\nif (cached) {\n  return JSON.parse(cached);\n}\n\nconst cut = await prisma.cut.findUnique({ where: { id: cutId } });\nawait redis.setex(CACHE_KEY, 3600, JSON.stringify(cut)); // 1 hour TTL\n\nreturn cut;\n
"},{"location":"v2/features/map/cuts/#related-documentation","title":"Related Documentation","text":"

Backend Modules:

Frontend Pages:

Database:

Features:

"},{"location":"v2/features/map/data-quality/","title":"Data Quality Dashboard","text":""},{"location":"v2/features/map/data-quality/#overview","title":"Overview","text":"

The Data Quality Dashboard provides comprehensive monitoring and management of geocoding accuracy and location data integrity. This feature enables campaign administrators to identify and resolve data quality issues, track geocoding provider performance, and ensure reliable map data for canvassing operations.

Key Features:

Use Cases:

Architecture Highlights:

"},{"location":"v2/features/map/data-quality/#architecture","title":"Architecture","text":"
flowchart TB\n    subgraph Admin Interface\n        Admin[Admin User]\n        Dashboard[DataQualityDashboardPage]\n        LocationsPage[LocationsPage]\n    end\n\n    subgraph API Layer\n        StatsAPI[\"/api/locations/geocode-stats\"]\n        LocationsAPI[\"/api/locations\"]\n        DuplicatesAPI[\"/api/locations/duplicates\"]\n        RegeocodeAPI[\"/api/locations/:id/regeocode\"]\n        BulkGeocodeAPI[\"/api/locations/bulk-geocode\"]\n    end\n\n    subgraph Database\n        LocationsDB[(Locations)]\n        Indexes[(Indexes)]\n    end\n\n    subgraph Geocoding Service\n        GeocodingService[GeocodingService]\n        Providers[6 Providers]\n        Cache[Redis Cache]\n    end\n\n    subgraph Monitoring\n        Prometheus[Prometheus]\n        Metrics[cm_locations_low_confidence_count]\n    end\n\n    Admin --> Dashboard\n    Admin --> LocationsPage\n\n    Dashboard --> StatsAPI\n    Dashboard --> LocationsAPI\n    Dashboard --> DuplicatesAPI\n    LocationsPage --> RegeocodeAPI\n    LocationsPage --> BulkGeocodeAPI\n\n    StatsAPI --> LocationsDB\n    LocationsAPI --> LocationsDB\n    DuplicatesAPI --> LocationsDB\n    RegeocodeAPI --> GeocodingService\n    BulkGeocodeAPI --> GeocodingService\n\n    LocationsDB --> Indexes\n    GeocodingService --> Providers\n    GeocodingService --> Cache\n\n    StatsAPI --> Prometheus\n    Prometheus --> Metrics

Data Flow:

  1. Statistics Aggregation:
  2. Query all locations with geocoding metadata
  3. Calculate aggregate metrics (total, geocoded %, avg confidence)
  4. Group by provider for success rate comparison
  5. Identify low-confidence locations (< 50)
  6. Detect duplicates via coordinate matching

  7. Quality Review:

  8. Admin views dashboard statistics
  9. Filters low-confidence locations
  10. Reviews individual location details
  11. Identifies patterns (provider failures, address format issues)

  12. Remediation:

  13. Manual address correction
  14. Single location re-geocoding
  15. Bulk re-geocoding with different provider
  16. Duplicate merging or marking

  17. Monitoring:

  18. Prometheus metrics track quality trends
  19. Alert rules trigger for quality degradation
  20. Grafana dashboards visualize provider performance
"},{"location":"v2/features/map/data-quality/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/data-quality/#location-model","title":"Location Model","text":"
model Location {\n  id          Int      @id @default(autoincrement())\n  address     String\n  latitude    Float?\n  longitude   Float?\n  postalCode  String?\n  province    String?\n\n  // Geocoding metadata\n  geocodeConfidence Int?        // 0-100 quality score\n  geocodeProvider   String?     // Provider used for geocoding\n  geocodedAt        DateTime?   // Timestamp of last geocode\n\n  // NAR import fields\n  locGuid           String?  @unique\n  federalDistrict   String?\n  buildingUse       Int?     // 1 = Residential\n\n  addresses   Address[]\n\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n\n  @@index([geocodeConfidence])\n  @@index([geocodeProvider])\n  @@index([latitude, longitude])\n  @@index([latitude, longitude], where: latitude IS NOT NULL AND longitude IS NOT NULL)\n}\n

Geocode Confidence Scale: - 0-20: Very Low (manual review required) - 21-40: Low (likely incorrect, re-geocode recommended) - 41-60: Medium (acceptable but consider verification) - 61-80: Good (likely accurate) - 81-100: Excellent (high confidence)

Geocode Provider Enum:

enum GeocodeProvider {\n  GOOGLE = 'GOOGLE',\n  MAPBOX = 'MAPBOX',\n  NOMINATIM = 'NOMINATIM',\n  PHOTON = 'PHOTON',\n  LOCATIONIQ = 'LOCATIONIQ',\n  ARCGIS = 'ARCGIS',\n  UNKNOWN = 'UNKNOWN'\n}\n

"},{"location":"v2/features/map/data-quality/#address-model","title":"Address Model","text":"
model Address {\n  id         Int      @id @default(autoincrement())\n  locationId Int\n  location   Location @relation(fields: [locationId], references: [id], onDelete: Cascade)\n\n  unitNumber   String?\n  firstName    String?\n  lastName     String?\n  supportLevel Int?\n  notes        String?\n\n  // Address validation\n  isValidated  Boolean  @default(false)\n  validatedAt  DateTime?\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([locationId])\n}\n
"},{"location":"v2/features/map/data-quality/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/map/data-quality/#get-apilocationsgeocode-stats","title":"GET /api/locations/geocode-stats","text":"

Fetch aggregate geocoding quality statistics.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Response:

{\n  \"total\": 1500,\n  \"geocoded\": 1450,\n  \"geocodedPercent\": 96.67,\n  \"avgConfidence\": 78.5,\n  \"providerBreakdown\": {\n    \"GOOGLE\": 800,\n    \"MAPBOX\": 350,\n    \"NOMINATIM\": 200,\n    \"PHOTON\": 100,\n    \"ARCGIS\": 0,\n    \"LOCATIONIQ\": 0,\n    \"UNKNOWN\": 50\n  },\n  \"confidenceDistribution\": {\n    \"0-20\": 15,\n    \"21-40\": 35,\n    \"41-60\": 150,\n    \"61-80\": 450,\n    \"81-100\": 800\n  },\n  \"lowConfidenceCount\": 50,\n  \"missingCoordinates\": 50,\n  \"duplicatesCount\": 12\n}\n

Implementation:

// locations.service.ts\nasync getGeocodeStats() {\n  const locations = await prisma.location.findMany({\n    select: {\n      latitude: true,\n      longitude: true,\n      geocodeConfidence: true,\n      geocodeProvider: true\n    }\n  });\n\n  const total = locations.length;\n  const geocoded = locations.filter(l => l.latitude && l.longitude).length;\n  const avgConfidence = locations.reduce((sum, l) =>\n    sum + (l.geocodeConfidence || 0), 0) / total;\n\n  const providerBreakdown = locations.reduce((acc, l) => {\n    const provider = l.geocodeProvider || 'UNKNOWN';\n    acc[provider] = (acc[provider] || 0) + 1;\n    return acc;\n  }, {} as Record<string, number>);\n\n  const confidenceDistribution = {\n    '0-20': 0,\n    '21-40': 0,\n    '41-60': 0,\n    '61-80': 0,\n    '81-100': 0\n  };\n\n  locations.forEach(l => {\n    const conf = l.geocodeConfidence || 0;\n    if (conf <= 20) confidenceDistribution['0-20']++;\n    else if (conf <= 40) confidenceDistribution['21-40']++;\n    else if (conf <= 60) confidenceDistribution['41-60']++;\n    else if (conf <= 80) confidenceDistribution['61-80']++;\n    else confidenceDistribution['81-100']++;\n  });\n\n  const lowConfidenceCount = locations.filter(l =>\n    (l.geocodeConfidence || 0) < 50).length;\n\n  return {\n    total,\n    geocoded,\n    geocodedPercent: (geocoded / total) * 100,\n    avgConfidence,\n    providerBreakdown,\n    confidenceDistribution,\n    lowConfidenceCount,\n    missingCoordinates: total - geocoded,\n    duplicatesCount: await this.countDuplicates()\n  };\n}\n
"},{"location":"v2/features/map/data-quality/#get-apilocationsgeocodeconfidencelt50","title":"GET /api/locations?geocodeConfidence=lt:50","text":"

Fetch locations filtered by geocode confidence.

Authentication: Required

Query Parameters: - geocodeConfidence (filter): lt:X, gt:X, eq:X, null - geocodeProvider (filter): Provider name (GOOGLE, MAPBOX, etc.) - page (optional): Page number (default: 1) - limit (optional): Results per page (default: 50) - sortBy (optional): Field to sort by (default: \"geocodeConfidence\") - order (optional): \"asc\" or \"desc\" (default: \"asc\")

Examples:

GET /api/locations?geocodeConfidence=lt:50\nGET /api/locations?geocodeConfidence=null\nGET /api/locations?geocodeProvider=NOMINATIM&geocodeConfidence=lt:70\nGET /api/locations?geocodeConfidence=gt:80&sortBy=address\n

Response:

{\n  \"data\": [\n    {\n      \"id\": 1001,\n      \"address\": \"123 Main St\",\n      \"latitude\": 43.6532,\n      \"longitude\": -79.3832,\n      \"postalCode\": \"M5H 2N2\",\n      \"geocodeConfidence\": 45,\n      \"geocodeProvider\": \"NOMINATIM\",\n      \"geocodedAt\": \"2025-02-10T10:00:00Z\",\n      \"addresses\": [...]\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 50,\n    \"total\": 150,\n    \"pages\": 3\n  }\n}\n

"},{"location":"v2/features/map/data-quality/#get-apilocationsduplicates","title":"GET /api/locations/duplicates","text":"

Identify locations with identical coordinates.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Query Parameters: - threshold (optional): Distance threshold in meters (default: 1, matches exact duplicates)

Response:

{\n  \"duplicates\": [\n    {\n      \"coordinates\": {\n        \"latitude\": 43.6532,\n        \"longitude\": -79.3832\n      },\n      \"count\": 3,\n      \"locations\": [\n        {\n          \"id\": 1001,\n          \"address\": \"123 Main St\",\n          \"postalCode\": \"M5H 2N2\"\n        },\n        {\n          \"id\": 1002,\n          \"address\": \"123 Main Street\",\n          \"postalCode\": \"M5H 2N2\"\n        },\n        {\n          \"id\": 1003,\n          \"address\": \"123 Main St, Unit 1\",\n          \"postalCode\": \"M5H 2N2\"\n        }\n      ]\n    }\n  ],\n  \"total\": 12\n}\n

Implementation:

// locations.service.ts\nasync findDuplicates(thresholdMeters: number = 1) {\n  const locations = await prisma.location.findMany({\n    where: {\n      AND: [\n        { latitude: { not: null } },\n        { longitude: { not: null } }\n      ]\n    },\n    select: {\n      id: true,\n      address: true,\n      latitude: true,\n      longitude: true,\n      postalCode: true\n    }\n  });\n\n  const coordMap = new Map<string, typeof locations>();\n\n  locations.forEach(loc => {\n    // Round to 6 decimal places (~0.1m precision)\n    const key = `${loc.latitude!.toFixed(6)},${loc.longitude!.toFixed(6)}`;\n    if (!coordMap.has(key)) {\n      coordMap.set(key, []);\n    }\n    coordMap.get(key)!.push(loc);\n  });\n\n  const duplicates = Array.from(coordMap.entries())\n    .filter(([_, locs]) => locs.length > 1)\n    .map(([coords, locs]) => {\n      const [lat, lng] = coords.split(',').map(Number);\n      return {\n        coordinates: { latitude: lat, longitude: lng },\n        count: locs.length,\n        locations: locs\n      };\n    });\n\n  return {\n    duplicates,\n    total: duplicates.reduce((sum, dup) => sum + dup.count, 0)\n  };\n}\n
"},{"location":"v2/features/map/data-quality/#post-apilocationsidregeocode","title":"POST /api/locations/:id/regeocode","text":"

Re-geocode a single location with specified provider.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Request Body:

{\n  \"provider\": \"GOOGLE\",\n  \"address\": \"123 Main St, Toronto ON M5H 2N2\"\n}\n

Parameters: - provider (optional): Specific provider to use (default: fallback chain) - address (optional): Override address string (default: use existing)

Response:

{\n  \"id\": 1001,\n  \"address\": \"123 Main St\",\n  \"latitude\": 43.6532,\n  \"longitude\": -79.3832,\n  \"geocodeConfidence\": 95,\n  \"geocodeProvider\": \"GOOGLE\",\n  \"geocodedAt\": \"2025-02-13T10:30:00Z\"\n}\n

"},{"location":"v2/features/map/data-quality/#post-apilocationsbulk-geocode","title":"POST /api/locations/bulk-geocode","text":"

Bulk re-geocode multiple locations.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Request Body:

{\n  \"locationIds\": [1001, 1002, 1003],\n  \"provider\": \"GOOGLE\",\n  \"confidenceThreshold\": 50\n}\n

Parameters: - locationIds (optional): Specific location IDs (default: all with confidence < threshold) - provider (optional): Specific provider to use (default: fallback chain) - confidenceThreshold (optional): Only re-geocode locations below this confidence (default: 50)

Response:

{\n  \"jobId\": \"bulk-geocode-20250213-103000\",\n  \"status\": \"queued\",\n  \"total\": 150,\n  \"message\": \"Bulk geocoding job started\"\n}\n

Job Progress Endpoint:

GET /api/locations/bulk-geocode/:jobId\n

Job Status Response:

{\n  \"jobId\": \"bulk-geocode-20250213-103000\",\n  \"status\": \"processing\",\n  \"progress\": {\n    \"total\": 150,\n    \"processed\": 75,\n    \"successful\": 70,\n    \"failed\": 5,\n    \"percent\": 50\n  },\n  \"startedAt\": \"2025-02-13T10:30:00Z\",\n  \"estimatedCompletion\": \"2025-02-13T10:35:00Z\"\n}\n

"},{"location":"v2/features/map/data-quality/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/data-quality/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description GEOCODE_CONFIDENCE_THRESHOLD number 50 Minimum confidence for acceptable geocoding GEOCODE_PRIMARY_PROVIDER string GOOGLE Primary geocoding provider GEOCODE_FALLBACK_PROVIDERS string MAPBOX,NOMINATIM Comma-separated fallback providers GEOCODE_CACHE_TTL number 2592000 Cache TTL in seconds (30 days)"},{"location":"v2/features/map/data-quality/#quality-thresholds","title":"Quality Thresholds","text":"Metric Warning Critical Description Geocoded % < 95% < 90% Percentage of locations with coordinates Avg Confidence < 70 < 60 Average geocode confidence score Low Confidence Count > 50 > 100 Locations with confidence < 50 Duplicates > 20 > 50 Locations with identical coordinates Missing Coordinates > 5% > 10% Locations without lat/lng"},{"location":"v2/features/map/data-quality/#prometheus-metrics","title":"Prometheus Metrics","text":"

Custom Metrics:

// api/src/utils/metrics.ts\n\nexport const geocodingQualityGauge = new Gauge({\n  name: 'cm_geocoding_avg_confidence',\n  help: 'Average geocoding confidence score (0-100)',\n  async collect() {\n    const stats = await locationsService.getGeocodeStats();\n    this.set(stats.avgConfidence);\n  }\n});\n\nexport const lowConfidenceLocationsGauge = new Gauge({\n  name: 'cm_locations_low_confidence_count',\n  help: 'Number of locations with geocode confidence < 50',\n  async collect() {\n    const stats = await locationsService.getGeocodeStats();\n    this.set(stats.lowConfidenceCount);\n  }\n});\n\nexport const geocodedPercentGauge = new Gauge({\n  name: 'cm_locations_geocoded_percent',\n  help: 'Percentage of locations with coordinates',\n  async collect() {\n    const stats = await locationsService.getGeocodeStats();\n    this.set(stats.geocodedPercent);\n  }\n});\n\nexport const duplicateLocationsGauge = new Gauge({\n  name: 'cm_locations_duplicates_count',\n  help: 'Number of duplicate location entries',\n  async collect() {\n    const duplicates = await locationsService.findDuplicates();\n    this.set(duplicates.total);\n  }\n});\n

Alert Rules:

# configs/prometheus/alerts.yml\n\ngroups:\n  - name: data_quality\n    interval: 5m\n    rules:\n      - alert: LowGeocodingConfidence\n        expr: cm_geocoding_avg_confidence < 60\n        for: 10m\n        labels:\n          severity: warning\n        annotations:\n          summary: Low average geocoding confidence\n          description: \"Average geocoding confidence is {{ $value }}, below threshold of 60\"\n\n      - alert: HighLowConfidenceLocations\n        expr: cm_locations_low_confidence_count > 100\n        for: 5m\n        labels:\n          severity: critical\n        annotations:\n          summary: High number of low-confidence locations\n          description: \"{{ $value }} locations have geocoding confidence < 50\"\n\n      - alert: LowGeocodedPercent\n        expr: cm_locations_geocoded_percent < 90\n        for: 10m\n        labels:\n          severity: warning\n        annotations:\n          summary: Low percentage of geocoded locations\n          description: \"Only {{ $value }}% of locations have coordinates\"\n\n      - alert: HighDuplicateLocations\n        expr: cm_locations_duplicates_count > 50\n        for: 15m\n        labels:\n          severity: warning\n        annotations:\n          summary: High number of duplicate locations\n          description: \"{{ $value }} duplicate location entries detected\"\n
"},{"location":"v2/features/map/data-quality/#quality-metrics","title":"Quality Metrics","text":""},{"location":"v2/features/map/data-quality/#geocoding-confidence","title":"Geocoding Confidence","text":"

Calculation:

Geocoding confidence is calculated based on multiple factors:

interface GeocodeResult {\n  latitude: number;\n  longitude: number;\n  matchType: 'exact' | 'interpolated' | 'approximate' | 'fallback';\n  addressComponents: {\n    streetNumber?: string;\n    street?: string;\n    city?: string;\n    postalCode?: string;\n    province?: string;\n  };\n  providerConfidence?: number; // Provider-specific score\n}\n\nfunction calculateConfidence(result: GeocodeResult, inputAddress: string): number {\n  let confidence = 0;\n\n  // Match type (0-40 points)\n  switch (result.matchType) {\n    case 'exact': confidence += 40; break;\n    case 'interpolated': confidence += 30; break;\n    case 'approximate': confidence += 20; break;\n    case 'fallback': confidence += 10; break;\n  }\n\n  // Address component completeness (0-30 points)\n  const components = result.addressComponents;\n  if (components.streetNumber) confidence += 10;\n  if (components.street) confidence += 10;\n  if (components.postalCode) confidence += 10;\n\n  // Provider-specific confidence (0-30 points)\n  if (result.providerConfidence) {\n    confidence += (result.providerConfidence / 100) * 30;\n  }\n\n  return Math.min(Math.round(confidence), 100);\n}\n

Confidence Levels:

"},{"location":"v2/features/map/data-quality/#provider-success-rates","title":"Provider Success Rates","text":"

Metrics Tracked:

interface ProviderMetrics {\n  provider: GeocodeProvider;\n  totalAttempts: number;\n  successfulGeocodes: number;\n  successRate: number; // 0-100%\n  avgConfidence: number; // 0-100\n  avgResponseTime: number; // milliseconds\n  errorCount: number;\n  lastError?: string;\n}\n

Success Rate Calculation:

const calculateProviderMetrics = async (): Promise<ProviderMetrics[]> => {\n  const locations = await prisma.location.findMany({\n    select: {\n      geocodeProvider: true,\n      geocodeConfidence: true,\n      latitude: true,\n      longitude: true\n    }\n  });\n\n  const providerGroups = groupBy(locations, 'geocodeProvider');\n\n  return Object.entries(providerGroups).map(([provider, locs]) => {\n    const total = locs.length;\n    const successful = locs.filter(l => l.latitude && l.longitude).length;\n    const avgConf = locs.reduce((sum, l) => sum + (l.geocodeConfidence || 0), 0) / total;\n\n    return {\n      provider: provider as GeocodeProvider,\n      totalAttempts: total,\n      successfulGeocodes: successful,\n      successRate: (successful / total) * 100,\n      avgConfidence: avgConf,\n      avgResponseTime: 0, // Would need separate tracking\n      errorCount: total - successful\n    };\n  });\n};\n
"},{"location":"v2/features/map/data-quality/#duplicate-detection","title":"Duplicate Detection","text":"

Detection Methods:

  1. Exact Coordinate Match:

    // Round to 6 decimal places (~0.1m precision)\nconst isDuplicateExact = (loc1: Location, loc2: Location): boolean => {\n  return loc1.latitude!.toFixed(6) === loc2.latitude!.toFixed(6) &&\n         loc1.longitude!.toFixed(6) === loc2.longitude!.toFixed(6);\n};\n

  2. Proximity Threshold:

    // Haversine distance check\nconst isDuplicateProximity = (loc1: Location, loc2: Location, thresholdM: number): boolean => {\n  const distance = haversineDistance(\n    [loc1.latitude!, loc1.longitude!],\n    [loc2.latitude!, loc2.longitude!]\n  );\n  return distance < thresholdM;\n};\n

  3. Address Similarity:

    import { distance as levenshteinDistance } from 'fastest-levenshtein';\n\nconst isDuplicateAddress = (addr1: string, addr2: string): boolean => {\n  const normalized1 = normalizeAddress(addr1);\n  const normalized2 = normalizeAddress(addr2);\n  const dist = levenshteinDistance(normalized1, normalized2);\n  const similarity = 1 - (dist / Math.max(normalized1.length, normalized2.length));\n  return similarity > 0.9; // 90% similar\n};\n\nconst normalizeAddress = (address: string): string => {\n  return address\n    .toLowerCase()\n    .replace(/\\bstreet\\b/g, 'st')\n    .replace(/\\bavenue\\b/g, 'ave')\n    .replace(/\\broad\\b/g, 'rd')\n    .replace(/\\bdrive\\b/g, 'dr')\n    .replace(/[^a-z0-9]/g, '');\n};\n

"},{"location":"v2/features/map/data-quality/#address-validation","title":"Address Validation","text":"

Validation Checks:

interface AddressValidationResult {\n  isValid: boolean;\n  issues: string[];\n  suggestions?: string[];\n}\n\nconst validateAddress = (address: string): AddressValidationResult => {\n  const issues: string[] = [];\n\n  // Check minimum length\n  if (address.length < 5) {\n    issues.push('Address too short');\n  }\n\n  // Check for street number\n  if (!/^\\d+/.test(address)) {\n    issues.push('Missing street number');\n  }\n\n  // Check for street name\n  if (!/\\d+\\s+([A-Za-z]+\\s*)+/.test(address)) {\n    issues.push('Missing street name');\n  }\n\n  // Check for postal code (Canadian format)\n  if (!/[A-Z]\\d[A-Z]\\s?\\d[A-Z]\\d/.test(address)) {\n    issues.push('Missing or invalid postal code');\n  }\n\n  // Check for unusual characters\n  if (/[^A-Za-z0-9\\s,.-]/.test(address)) {\n    issues.push('Contains unusual characters');\n  }\n\n  return {\n    isValid: issues.length === 0,\n    issues\n  };\n};\n
"},{"location":"v2/features/map/data-quality/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/map/data-quality/#navigate-to-data-quality-dashboard","title":"Navigate to Data Quality Dashboard","text":"

Step 1: Access Dashboard

  1. Log in as SUPER_ADMIN or MAP_ADMIN
  2. Click Map in sidebar
  3. Click Data Quality submenu
  4. Dashboard loads with statistics

Step 2: Review Overall Statistics

Dashboard displays 4 main statistic cards:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Total Locations  \u2502 Geocoded         \u2502 Avg Confidence   \u2502 Low Confidence   \u2502\n\u2502 1,500            \u2502 1,450 (96.7%)    \u2502 78.5             \u2502 50               \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n

Step 3: Analyze Provider Performance

Provider breakdown table shows:

Provider Count Success Rate Avg Confidence GOOGLE 800 99.2% 85.3 MAPBOX 350 97.1% 82.1 NOMINATIM 200 94.5% 75.8 PHOTON 100 91.0% 68.2 UNKNOWN 50 N/A 0

Step 4: Review Confidence Distribution

Bar chart displays confidence distribution:

Confidence Distribution\n100 |              \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n 80 |              \u2502      \u2502\n 60 |        \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2524      \u2502\n 40 |  \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2524      \u2502      \u2502\n 20 |  \u2502      \u2502      \u2502      \u2502\n  0 \u2514\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n    0-20  21-40  41-60  61-80 81-100\n     15     35    150    450    800\n
"},{"location":"v2/features/map/data-quality/#identify-and-review-low-confidence-locations","title":"Identify and Review Low-Confidence Locations","text":"

Step 1: Filter Low-Confidence Locations

  1. Click Low Confidence tab on dashboard
  2. Table loads with locations where confidence < 50
  3. Sort by confidence (ascending) to prioritize worst

Step 2: Review Location Details

Click row to open detail drawer:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Location Details                        \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 Address: 123 Main St                    \u2502\n\u2502 Postal Code: M5H 2N2                    \u2502\n\u2502 Coordinates: 43.6532, -79.3832          \u2502\n\u2502                                         \u2502\n\u2502 Geocoding Info:                         \u2502\n\u2502   Confidence: 45 (Low)                  \u2502\n\u2502   Provider: NOMINATIM                   \u2502\n\u2502   Geocoded: Feb 10, 2025 10:00 AM      \u2502\n\u2502                                         \u2502\n\u2502 Issues:                                 \u2502\n\u2502   \u2022 Missing street number in response   \u2502\n\u2502   \u2022 Approximate match only              \u2502\n\u2502                                         \u2502\n\u2502 [Re-geocode] [Edit Address] [View Map] \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n

Step 3: Take Action

Options for remediation:

  1. Re-geocode with different provider:
  2. Click Re-geocode button
  3. Select provider (GOOGLE recommended for low confidence)
  4. Click Geocode Now
  5. New confidence displayed

  6. Edit address:

  7. Click Edit Address
  8. Correct typos or formatting issues
  9. Save changes
  10. Auto-triggers re-geocoding

  11. View on map:

  12. Click View Map
  13. Verify location accuracy visually
  14. Drag marker to correct position if needed
"},{"location":"v2/features/map/data-quality/#bulk-re-geocoding","title":"Bulk Re-geocoding","text":"

Step 1: Select Locations

  1. In Low Confidence tab, use table checkboxes to select locations
  2. Or click Select All to select all visible
  3. Selected count displays: \"50 selected\"

Step 2: Choose Provider

  1. Click Bulk Re-geocode button
  2. Modal opens with provider selection:
    \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Bulk Re-geocode                     \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 Re-geocode 50 locations             \u2502\n\u2502                                     \u2502\n\u2502 Provider: [GOOGLE \u25bc]                \u2502\n\u2502                                     \u2502\n\u2502 Options:                            \u2502\n\u2502 \u2611 Only if confidence < 50           \u2502\n\u2502 \u2611 Cache results                     \u2502\n\u2502 \u2610 Overwrite existing coordinates    \u2502\n\u2502                                     \u2502\n\u2502 Estimated time: ~2 minutes          \u2502\n\u2502                                     \u2502\n\u2502 [Cancel] [Start Re-geocoding]       \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n

Step 3: Monitor Progress

  1. Job starts, progress bar appears:

    Re-geocoding in progress... 25/50 (50%)\n[\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591] 50%\n

  2. Real-time updates:

  3. Total processed
  4. Successful geocodes
  5. Failed geocodes
  6. Average new confidence

Step 4: Review Results

Job completion summary:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Bulk Re-geocode Complete            \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 Processed: 50                       \u2502\n\u2502 Successful: 47 (94%)                \u2502\n\u2502 Failed: 3 (6%)                      \u2502\n\u2502                                     \u2502\n\u2502 Quality Improvement:                \u2502\n\u2502   Avg Confidence Before: 42.5       \u2502\n\u2502   Avg Confidence After: 81.3        \u2502\n\u2502   Improvement: +38.8                \u2502\n\u2502                                     \u2502\n\u2502 [View Failed] [Close]               \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n
"},{"location":"v2/features/map/data-quality/#handle-duplicates","title":"Handle Duplicates","text":"

Step 1: View Duplicates Tab

  1. Click Duplicates tab on dashboard
  2. Table groups locations by coordinates

Step 2: Review Duplicate Groups

Table displays:

Coordinates Count Addresses Action 43.6532, -79.3832 3 123 Main St, 123 Main Street, 123 Main St Unit 1 [Review] 43.6540, -79.3825 2 456 Bay St, 456 Bay Street [Review]

Step 3: Resolve Duplicates

Click Review to open resolution modal:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Resolve Duplicates                  \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 3 locations at 43.6532, -79.3832    \u2502\n\u2502                                     \u2502\n\u2502 \u25cb Merge into single location        \u2502\n\u2502   Primary: 123 Main St              \u2502\n\u2502   Merge units from duplicates       \u2502\n\u2502                                     \u2502\n\u2502 \u25cb Keep as separate multi-unit       \u2502\n\u2502   Mark as validated multi-unit      \u2502\n\u2502                                     \u2502\n\u2502 \u25cb Re-geocode individually           \u2502\n\u2502   Try to get unique coordinates     \u2502\n\u2502                                     \u2502\n\u2502 [Cancel] [Resolve]                  \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n

Resolution Options:

  1. Merge: Combine into single Location with multiple Address records
  2. Multi-unit: Mark as legitimate multi-unit building
  3. Re-geocode: Attempt to get unique coordinates for each
"},{"location":"v2/features/map/data-quality/#quality-improvement-strategies","title":"Quality Improvement Strategies","text":""},{"location":"v2/features/map/data-quality/#multi-provider-geocoding","title":"Multi-Provider Geocoding","text":"

Fallback Chain:

// geocoding.service.ts\n\nconst PROVIDER_CHAIN: GeocodeProvider[] = [\n  'GOOGLE',    // Primary: Best accuracy, paid\n  'MAPBOX',    // Fallback 1: Good accuracy, paid\n  'NOMINATIM', // Fallback 2: Free, decent accuracy\n  'PHOTON',    // Fallback 3: Free, lower accuracy\n  'ARCGIS'     // Fallback 4: Free, basic accuracy\n];\n\nasync geocode(address: string): Promise<GeocodeResult | null> {\n  for (const provider of PROVIDER_CHAIN) {\n    try {\n      const result = await this.geocodeWithProvider(address, provider);\n      if (result && result.confidence >= 50) {\n        return result; // Success, confidence acceptable\n      }\n    } catch (error) {\n      logger.warn(`Geocoding failed with ${provider}:`, error);\n      // Try next provider\n    }\n  }\n  return null; // All providers failed\n}\n

Benefits: - Increases success rate (90% \u2192 96%+) - Reduces dependency on single provider - Cost optimization (use free providers as fallback) - Provider outage resilience

"},{"location":"v2/features/map/data-quality/#address-normalization","title":"Address Normalization","text":"

Pre-Geocoding Normalization:

const normalizeAddressForGeocoding = (address: string): string => {\n  let normalized = address;\n\n  // Remove extra whitespace\n  normalized = normalized.replace(/\\s+/g, ' ').trim();\n\n  // Standardize abbreviations\n  const replacements: Record<string, string> = {\n    'Street': 'St',\n    'Avenue': 'Ave',\n    'Road': 'Rd',\n    'Drive': 'Dr',\n    'Boulevard': 'Blvd',\n    'Apartment': 'Apt',\n    'Unit': 'Unit',\n    'Suite': 'Ste'\n  };\n\n  Object.entries(replacements).forEach(([long, short]) => {\n    const regex = new RegExp(`\\\\b${long}\\\\b`, 'gi');\n    normalized = normalized.replace(regex, short);\n  });\n\n  // Ensure postal code spacing (Canadian format)\n  normalized = normalized.replace(/([A-Z]\\d[A-Z])(\\d[A-Z]\\d)/, '$1 $2');\n\n  // Remove periods from abbreviations\n  normalized = normalized.replace(/\\./g, '');\n\n  return normalized;\n};\n

Improvements: - Reduces geocoding errors by 10-15% - Increases confidence scores - Better cache hit rate

"},{"location":"v2/features/map/data-quality/#geocoding-cache","title":"Geocoding Cache","text":"

Redis Cache Implementation:

// geocoding.service.ts\n\nprivate async geocodeWithCache(address: string): Promise<GeocodeResult | null> {\n  const cacheKey = `geocode:${normalizeAddress(address)}`;\n\n  // Check cache\n  const cached = await redis.get(cacheKey);\n  if (cached) {\n    logger.debug('Geocoding cache hit:', address);\n    return JSON.parse(cached);\n  }\n\n  // Cache miss, geocode\n  const result = await this.geocode(address);\n  if (result) {\n    // Cache for 30 days\n    await redis.setex(cacheKey, 2592000, JSON.stringify(result));\n  }\n\n  return result;\n}\n

Benefits: - Reduces API costs (90% cache hit rate) - Faster response times (Redis: <5ms vs API: 200-500ms) - Consistent results for same address - Provider API rate limit avoidance

"},{"location":"v2/features/map/data-quality/#manual-verification","title":"Manual Verification","text":"

Critical Location Verification:

Manually verify high-priority locations:

  1. Campaign offices: Ensure exact coordinates
  2. Shift start points: Verify accessibility
  3. Event venues: Confirm entrance location
  4. Polling stations: Critical for voter info

Verification Process:

// Mark location as manually verified\nawait prisma.location.update({\n  where: { id: locationId },\n  data: {\n    geocodeConfidence: 100,\n    geocodeProvider: 'MANUAL',\n    geocodedAt: new Date()\n  }\n});\n
"},{"location":"v2/features/map/data-quality/#regular-audits","title":"Regular Audits","text":"

Monthly Quality Audit Checklist:

  1. Run quality report:

    curl http://localhost:4000/api/locations/geocode-stats\n

  2. Check metrics against thresholds:

  3. Geocoded % > 95%
  4. Avg confidence > 70
  5. Low confidence count < 50
  6. Duplicates < 20

  7. Review low-confidence locations:

  8. Filter locations with confidence < 50
  9. Review top 20 by address
  10. Identify patterns (specific streets, providers)

  11. Bulk re-geocode low confidence:

  12. Use GOOGLE provider for accuracy
  13. Monitor improvement in avg confidence

  14. Resolve duplicates:

  15. Review all duplicate groups
  16. Merge or mark as multi-unit
  17. Update addresses as needed

  18. Export quality report:

    const report = await generateQualityReport();\nfs.writeFileSync(`quality-report-${date}.json`, JSON.stringify(report, null, 2));\n

"},{"location":"v2/features/map/data-quality/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/data-quality/#dataqualitydashboardpagetsx","title":"DataQualityDashboardPage.tsx","text":"
import React, { useEffect, useState } from 'react';\nimport { Card, Row, Col, Statistic, Table, Tabs, Button, message } from 'antd';\nimport { WarningOutlined, CheckCircleOutlined } from '@ant-design/icons';\nimport { api } from '@/lib/api';\nimport { Bar } from 'react-chartjs-2';\n\ninterface GeocodeStats {\n  total: number;\n  geocoded: number;\n  geocodedPercent: number;\n  avgConfidence: number;\n  providerBreakdown: Record<string, number>;\n  confidenceDistribution: Record<string, number>;\n  lowConfidenceCount: number;\n  missingCoordinates: number;\n  duplicatesCount: number;\n}\n\nconst DataQualityDashboardPage: React.FC = () => {\n  const [stats, setStats] = useState<GeocodeStats | null>(null);\n  const [lowConfLocations, setLowConfLocations] = useState<any[]>([]);\n  const [duplicates, setDuplicates] = useState<any[]>([]);\n  const [loading, setLoading] = useState(false);\n\n  useEffect(() => {\n    fetchStats();\n    fetchLowConfidenceLocations();\n    fetchDuplicates();\n  }, []);\n\n  const fetchStats = async () => {\n    setLoading(true);\n    try {\n      const { data } = await api.get<GeocodeStats>('/locations/geocode-stats');\n      setStats(data);\n    } catch (error) {\n      message.error('Failed to load statistics');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const fetchLowConfidenceLocations = async () => {\n    try {\n      const { data } = await api.get('/locations?geocodeConfidence=lt:50&limit=100');\n      setLowConfLocations(data.data);\n    } catch (error) {\n      message.error('Failed to load low-confidence locations');\n    }\n  };\n\n  const fetchDuplicates = async () => {\n    try {\n      const { data } = await api.get('/locations/duplicates');\n      setDuplicates(data.duplicates);\n    } catch (error) {\n      message.error('Failed to load duplicates');\n    }\n  };\n\n  const handleRegeocodeLocation = async (locationId: number) => {\n    try {\n      await api.post(`/locations/${locationId}/regeocode`, { provider: 'GOOGLE' });\n      message.success('Location re-geocoded successfully');\n      fetchStats();\n      fetchLowConfidenceLocations();\n    } catch (error) {\n      message.error('Failed to re-geocode location');\n    }\n  };\n\n  const confidenceChartData = stats ? {\n    labels: Object.keys(stats.confidenceDistribution),\n    datasets: [{\n      label: 'Locations',\n      data: Object.values(stats.confidenceDistribution),\n      backgroundColor: [\n        '#e74c3c', // 0-20: Red\n        '#f39c12', // 21-40: Orange\n        '#f1c40f', // 41-60: Yellow\n        '#3498db', // 61-80: Blue\n        '#27ae60'  // 81-100: Green\n      ]\n    }]\n  } : null;\n\n  const lowConfColumns = [\n    { title: 'Address', dataIndex: 'address', key: 'address' },\n    { title: 'Confidence', dataIndex: 'geocodeConfidence', key: 'confidence', render: (val: number) => (\n      <span style={{ color: val < 30 ? '#e74c3c' : '#f39c12' }}>{val}</span>\n    )},\n    { title: 'Provider', dataIndex: 'geocodeProvider', key: 'provider' },\n    { title: 'Action', key: 'action', render: (_: any, record: any) => (\n      <Button size=\"small\" onClick={() => handleRegeocodeLocation(record.id)}>\n        Re-geocode\n      </Button>\n    )}\n  ];\n\n  return (\n    <div>\n      <h1>Data Quality Dashboard</h1>\n\n      {/* Statistics Cards */}\n      <Row gutter={16} style={{ marginBottom: 24 }}>\n        <Col span={6}>\n          <Card>\n            <Statistic\n              title=\"Total Locations\"\n              value={stats?.total || 0}\n              prefix={<CheckCircleOutlined />}\n            />\n          </Card>\n        </Col>\n        <Col span={6}>\n          <Card>\n            <Statistic\n              title=\"Geocoded\"\n              value={stats?.geocoded || 0}\n              suffix={`(${stats?.geocodedPercent.toFixed(1) || 0}%)`}\n              valueStyle={{ color: (stats?.geocodedPercent || 0) > 95 ? '#27ae60' : '#f39c12' }}\n            />\n          </Card>\n        </Col>\n        <Col span={6}>\n          <Card>\n            <Statistic\n              title=\"Avg Confidence\"\n              value={stats?.avgConfidence.toFixed(1) || 0}\n              valueStyle={{ color: (stats?.avgConfidence || 0) > 70 ? '#27ae60' : '#f39c12' }}\n            />\n          </Card>\n        </Col>\n        <Col span={6}>\n          <Card>\n            <Statistic\n              title=\"Low Confidence\"\n              value={stats?.lowConfidenceCount || 0}\n              prefix={<WarningOutlined />}\n              valueStyle={{ color: (stats?.lowConfidenceCount || 0) > 50 ? '#e74c3c' : '#f39c12' }}\n            />\n          </Card>\n        </Col>\n      </Row>\n\n      {/* Charts and Tables */}\n      <Tabs\n        items={[\n          {\n            key: 'overview',\n            label: 'Overview',\n            children: (\n              <div>\n                <Card title=\"Confidence Distribution\" style={{ marginBottom: 24 }}>\n                  {confidenceChartData && <Bar data={confidenceChartData} />}\n                </Card>\n                <Card title=\"Provider Performance\">\n                  <Table\n                    dataSource={stats ? Object.entries(stats.providerBreakdown).map(([provider, count]) => ({\n                      provider,\n                      count\n                    })) : []}\n                    columns={[\n                      { title: 'Provider', dataIndex: 'provider', key: 'provider' },\n                      { title: 'Count', dataIndex: 'count', key: 'count' }\n                    ]}\n                    pagination={false}\n                  />\n                </Card>\n              </div>\n            )\n          },\n          {\n            key: 'low-confidence',\n            label: `Low Confidence (${lowConfLocations.length})`,\n            children: (\n              <Table\n                dataSource={lowConfLocations}\n                columns={lowConfColumns}\n                rowKey=\"id\"\n                loading={loading}\n              />\n            )\n          },\n          {\n            key: 'duplicates',\n            label: `Duplicates (${duplicates.length})`,\n            children: (\n              <Table\n                dataSource={duplicates}\n                columns={[\n                  { title: 'Coordinates', key: 'coords', render: (_, record: any) =>\n                    `${record.coordinates.latitude.toFixed(6)}, ${record.coordinates.longitude.toFixed(6)}`\n                  },\n                  { title: 'Count', dataIndex: 'count', key: 'count' },\n                  { title: 'Addresses', key: 'addresses', render: (_, record: any) =>\n                    record.locations.map((l: any) => l.address).join(', ')\n                  }\n                ]}\n                rowKey={(record) => `${record.coordinates.latitude}-${record.coordinates.longitude}`}\n              />\n            )\n          }\n        ]}\n      />\n    </div>\n  );\n};\n\nexport default DataQualityDashboardPage;\n
"},{"location":"v2/features/map/data-quality/#geocode-statistics-service","title":"Geocode Statistics Service","text":"
// locations.service.ts\n\nimport { prisma } from '@/config/database';\nimport type { GeocodeProvider } from '@prisma/client';\n\nexport class LocationsService {\n  async getGeocodeStats() {\n    const locations = await prisma.location.findMany({\n      select: {\n        id: true,\n        latitude: true,\n        longitude: true,\n        geocodeConfidence: true,\n        geocodeProvider: true\n      }\n    });\n\n    const total = locations.length;\n    const geocoded = locations.filter(l => l.latitude && l.longitude).length;\n\n    const sumConfidence = locations.reduce((sum, l) => sum + (l.geocodeConfidence || 0), 0);\n    const avgConfidence = total > 0 ? sumConfidence / total : 0;\n\n    // Provider breakdown\n    const providerBreakdown: Record<string, number> = {};\n    locations.forEach(l => {\n      const provider = l.geocodeProvider || 'UNKNOWN';\n      providerBreakdown[provider] = (providerBreakdown[provider] || 0) + 1;\n    });\n\n    // Confidence distribution\n    const confidenceDistribution = {\n      '0-20': 0,\n      '21-40': 0,\n      '41-60': 0,\n      '61-80': 0,\n      '81-100': 0\n    };\n\n    locations.forEach(l => {\n      const conf = l.geocodeConfidence || 0;\n      if (conf <= 20) confidenceDistribution['0-20']++;\n      else if (conf <= 40) confidenceDistribution['21-40']++;\n      else if (conf <= 60) confidenceDistribution['41-60']++;\n      else if (conf <= 80) confidenceDistribution['61-80']++;\n      else confidenceDistribution['81-100']++;\n    });\n\n    const lowConfidenceCount = locations.filter(l => (l.geocodeConfidence || 0) < 50).length;\n    const duplicatesCount = await this.countDuplicates();\n\n    return {\n      total,\n      geocoded,\n      geocodedPercent: total > 0 ? (geocoded / total) * 100 : 0,\n      avgConfidence,\n      providerBreakdown,\n      confidenceDistribution,\n      lowConfidenceCount,\n      missingCoordinates: total - geocoded,\n      duplicatesCount\n    };\n  }\n\n  async countDuplicates(): Promise<number> {\n    const locations = await prisma.location.findMany({\n      where: {\n        AND: [\n          { latitude: { not: null } },\n          { longitude: { not: null } }\n        ]\n      },\n      select: { latitude: true, longitude: true }\n    });\n\n    const coordMap = new Map<string, number>();\n    locations.forEach(l => {\n      const key = `${l.latitude!.toFixed(6)},${l.longitude!.toFixed(6)}`;\n      coordMap.set(key, (coordMap.get(key) || 0) + 1);\n    });\n\n    return Array.from(coordMap.values()).filter(count => count > 1).reduce((sum, count) => sum + count, 0);\n  }\n\n  async regeocode(locationId: number, provider?: GeocodeProvider) {\n    const location = await prisma.location.findUnique({\n      where: { id: locationId }\n    });\n\n    if (!location) {\n      throw new Error('Location not found');\n    }\n\n    const result = await geocodingService.geocode(location.address, provider);\n\n    if (!result) {\n      throw new Error('Geocoding failed');\n    }\n\n    return await prisma.location.update({\n      where: { id: locationId },\n      data: {\n        latitude: result.latitude,\n        longitude: result.longitude,\n        geocodeConfidence: result.confidence,\n        geocodeProvider: result.provider,\n        geocodedAt: new Date()\n      }\n    });\n  }\n}\n
"},{"location":"v2/features/map/data-quality/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/data-quality/#problem-many-low-confidence-locations","title":"Problem: Many low-confidence locations","text":"

Symptoms: - > 100 locations with confidence < 50 - Avg confidence < 60 - Prometheus alert firing

Solutions:

  1. Check provider API keys:

    # Test Google Geocoding API\ncurl \"https://maps.googleapis.com/maps/api/geocode/json?address=123+Main+St+Toronto&key=YOUR_KEY\"\n\n# Verify key in .env\necho $GEOCODE_GOOGLE_API_KEY\n

  2. Try different primary provider:

    # In .env, change primary provider\nGEOCODE_PRIMARY_PROVIDER=GOOGLE  # Most accurate\n# Or try:\nGEOCODE_PRIMARY_PROVIDER=MAPBOX  # Good alternative\n

  3. Verify address format:

    // Bad: Missing city/postal\n\"123 Main St\"\n\n// Good: Full address\n\"123 Main St, Toronto ON M5H 2N2\"\n

  4. Use postal code for better accuracy:

    // Append postal code if available\nconst fullAddress = location.postalCode\n  ? `${location.address}, ${location.postalCode}`\n  : location.address;\n

  5. Bulk re-geocode with Google:

    # Via API\ncurl -X POST http://localhost:4000/api/locations/bulk-geocode \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -d '{\"provider\":\"GOOGLE\",\"confidenceThreshold\":50}'\n

"},{"location":"v2/features/map/data-quality/#problem-duplicate-locations-detected","title":"Problem: Duplicate locations detected","text":"

Symptoms: - Multiple locations at same coordinates - Duplicates tab shows many groups - Inflated location counts in cuts

Solutions:

  1. Check if legitimately multi-unit:

    -- Find buildings with multiple addresses\nSELECT l.id, l.address, COUNT(a.id) as unit_count\nFROM \"Location\" l\nJOIN \"Address\" a ON a.\"locationId\" = l.id\nGROUP BY l.id\nHAVING COUNT(a.id) > 1;\n

  2. Verify geocoding precision:

    // Check if rounding issue\nconst isDuplicateRounding = (loc1, loc2) => {\n  // Use 4 decimal places (~11m precision) instead of 6 (~0.1m)\n  return loc1.latitude.toFixed(4) === loc2.latitude.toFixed(4) &&\n         loc1.longitude.toFixed(4) === loc2.longitude.toFixed(4);\n};\n

  3. Review NAR import process:

    // Ensure LOC_GUID unique constraint\nconst location = await prisma.location.upsert({\n  where: { locGuid: narRecord.LOC_GUID },\n  update: { /* update fields */ },\n  create: { /* create fields */ }\n});\n

  4. Merge duplicates:

    // Merge function\nconst mergeDuplicates = async (primaryId: number, duplicateIds: number[]) => {\n  // Move addresses to primary location\n  await prisma.address.updateMany({\n    where: { locationId: { in: duplicateIds } },\n    data: { locationId: primaryId }\n  });\n\n  // Delete duplicates\n  await prisma.location.deleteMany({\n    where: { id: { in: duplicateIds } }\n  });\n};\n

"},{"location":"v2/features/map/data-quality/#problem-geocoding-stats-slow-to-load","title":"Problem: Geocoding stats slow to load","text":"

Symptoms: - GET /api/locations/geocode-stats takes > 5 seconds - Dashboard timeout errors - High database CPU

Solutions:

  1. Add database indexes:

    CREATE INDEX CONCURRENTLY idx_locations_geocode_confidence\n  ON \"Location\"(geocodeConfidence);\n\nCREATE INDEX CONCURRENTLY idx_locations_geocode_provider\n  ON \"Location\"(geocodeProvider);\n\nCREATE INDEX CONCURRENTLY idx_locations_coords\n  ON \"Location\"(latitude, longitude)\n  WHERE latitude IS NOT NULL AND longitude IS NOT NULL;\n

  2. Cache stats in Redis:

    // Cache for 5 minutes\nconst getCachedStats = async () => {\n  const cached = await redis.get('geocode:stats');\n  if (cached) return JSON.parse(cached);\n\n  const stats = await locationsService.getGeocodeStats();\n  await redis.setex('geocode:stats', 300, JSON.stringify(stats));\n  return stats;\n};\n

  3. Use aggregation pipeline:

    // Raw SQL for better performance\nconst stats = await prisma.$queryRaw`\n  SELECT\n    COUNT(*) as total,\n    COUNT(latitude) as geocoded,\n    AVG(COALESCE(\"geocodeConfidence\", 0)) as avg_confidence,\n    \"geocodeProvider\",\n    COUNT(*) FILTER (WHERE \"geocodeConfidence\" < 50) as low_confidence\n  FROM \"Location\"\n  GROUP BY \"geocodeProvider\"\n`;\n

  4. Materialize stats view:

    -- Create materialized view\nCREATE MATERIALIZED VIEW geocode_stats_mv AS\nSELECT\n  COUNT(*) as total,\n  COUNT(latitude) FILTER (WHERE latitude IS NOT NULL) as geocoded,\n  AVG(COALESCE(\"geocodeConfidence\", 0)) as avg_confidence,\n  COUNT(*) FILTER (WHERE \"geocodeConfidence\" < 50) as low_confidence\nFROM \"Location\";\n\n-- Refresh hourly\nREFRESH MATERIALIZED VIEW geocode_stats_mv;\n

"},{"location":"v2/features/map/data-quality/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/data-quality/#database-query-optimization","title":"Database Query Optimization","text":"

Indexes: - geocodeConfidence (filtering) - geocodeProvider (grouping) - (latitude, longitude) composite (duplicate detection) - Partial index on non-null coordinates

Query Performance: - geocode-stats: ~500ms (1500 locations) - Low confidence filter: ~100ms (with index) - Duplicate detection: ~200ms (coordinate grouping) - Bulk re-geocode: ~2-5 min (150 locations, depends on provider)

"},{"location":"v2/features/map/data-quality/#api-rate-limits","title":"API Rate Limits","text":"

Provider Limits: - Google: 50 QPS, $5/1000 requests - Mapbox: 100,000/month free, then $0.50/1000 - Nominatim: 1 QPS (public), no commercial use - Photon: No official limit, self-hosted recommended - ArcGIS: 100,000/month free

Optimization: - Use Redis cache (30-day TTL) - Batch geocoding jobs (avoid rate limits) - Fallback to free providers for non-critical - Monitor usage via provider dashboards

"},{"location":"v2/features/map/data-quality/#caching-strategy","title":"Caching Strategy","text":"

Cache Layers:

  1. Application Cache (Redis):

    // 30-day TTL for geocode results\nconst cacheKey = `geocode:${normalizeAddress(address)}`;\nawait redis.setex(cacheKey, 2592000, JSON.stringify(result));\n

  2. Statistics Cache:

    // 5-minute TTL for stats\nawait redis.setex('geocode:stats', 300, JSON.stringify(stats));\n

  3. Provider Response Cache:

    // Cache raw provider responses separately\nawait redis.setex(`provider:${provider}:${address}`, 604800, JSON.stringify(rawResponse));\n

Cache Hit Rates: - Geocoding: 90%+ (repeated addresses) - Statistics: 95%+ (frequent dashboard views) - Provider responses: 85%+ (re-geocoding attempts)

"},{"location":"v2/features/map/data-quality/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/map/data-quality/#backend-documentation","title":"Backend Documentation","text":""},{"location":"v2/features/map/data-quality/#frontend-documentation","title":"Frontend Documentation","text":""},{"location":"v2/features/map/data-quality/#database-documentation","title":"Database Documentation","text":""},{"location":"v2/features/map/data-quality/#monitoring-documentation","title":"Monitoring Documentation","text":""},{"location":"v2/features/map/data-quality/#external-resources","title":"External Resources","text":""},{"location":"v2/features/map/geocoding/","title":"Multi-Provider Geocoding Service","text":""},{"location":"v2/features/map/geocoding/#overview","title":"Overview","text":"

The geocoding service provides automated address-to-coordinate conversion using a six-provider fallback chain. It enables campaigns to quickly convert voter addresses to map coordinates, with confidence scoring, Redis caching, and BullMQ queue integration for bulk operations.

Key Capabilities:

Use Cases:

"},{"location":"v2/features/map/geocoding/#architecture","title":"Architecture","text":"
graph TD\n    A[Location Service] -->|Geocode Request| B[Geocoding Service]\n    B -->|Check Cache| C[(Redis Cache)]\n    C -->|Cache Hit| A\n    C -->|Cache Miss| D[Provider Chain]\n\n    D -->|Try Provider 1| E[Google Geocoding API]\n    E -->|Success| F[Confidence Scorer]\n    E -->|Fail| G[Try Provider 2]\n    G -->|Mapbox| H[Mapbox Geocoding API]\n    H -->|Success| F\n    H -->|Fail| I[Try Provider 3]\n    I -->|Nominatim| J[Nominatim API]\n    J -->|Success| F\n    J -->|Fail| K[Try Provider 4]\n    K -->|Photon| L[Photon API]\n    L -->|Success| F\n    L -->|Fail| M[Try Provider 5]\n    M -->|LocationIQ| N[LocationIQ API]\n    N -->|Success| F\n    N -->|Fail| O[Try Provider 6]\n    O -->|ArcGIS| P[ArcGIS API]\n    P -->|Success| F\n    P -->|Fail| Q[Geocoding Failed]\n\n    F -->|Store Result| C\n    F -->|Return| A\n\n    R[Bulk Geocode Job] -->|Queue| S[(BullMQ)]\n    S -->|Process Batch| B\n    B -->|Rate Limit| T[Rate Limiter]\n    T -->|Allow| D\n\n    style C fill:#fff4e1\n    style S fill:#fff4e1\n    style E fill:#e8f5e9\n    style H fill:#e8f5e9\n    style J fill:#e8f5e9\n    style L fill:#e8f5e9\n    style N fill:#e8f5e9\n    style P fill:#e8f5e9

Flow Description:

  1. Location service requests geocode \u2192 Geocoding service checks Redis cache
  2. Cache miss \u2192 Try providers in configured order (Google \u2192 Mapbox \u2192 Nominatim \u2192 Photon \u2192 LocationIQ \u2192 ArcGIS)
  3. Provider success \u2192 Calculate confidence score (0-100) based on match type
  4. Cache result \u2192 Store in Redis with 7-day TTL
  5. Bulk geocoding \u2192 BullMQ worker processes batches with rate limiting
  6. Metrics tracking \u2192 Prometheus gauges for provider health and cache hit rate
"},{"location":"v2/features/map/geocoding/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/geocoding/#geocodeprovider-enum","title":"GeocodeProvider Enum","text":"

See Location Model Documentation for full schema.

Provider Enum Values:

enum GeocodeProvider {\n  GOOGLE\n  MAPBOX\n  NOMINATIM\n  PHOTON\n  LOCATIONIQ\n  ARCGIS\n  UNKNOWN\n}\n

Location Model Geocoding Fields:

Related Models:

"},{"location":"v2/features/map/geocoding/#api-endpoints","title":"API Endpoints","text":"

See Geocoding Backend Module Documentation for full API reference.

Geocoding Endpoints:

Method Endpoint Auth Description POST /api/map/locations/geocode MAP_ADMIN Geocode single address POST /api/map/locations/reverse-geocode MAP_ADMIN Reverse geocode lat/lng to address POST /api/map/locations/bulk-geocode/start MAP_ADMIN Start bulk geocoding job (BullMQ) GET /api/map/locations/bulk-geocode/status MAP_ADMIN Check bulk geocoding job status POST /api/map/locations/bulk-geocode/cancel MAP_ADMIN Cancel running bulk geocoding job

Request/Response Examples:

Single Geocode Request:

POST /api/map/locations/geocode\n{\n  \"address\": \"123 Main Street, Ottawa, ON K1A 0B1\"\n}\n\n// Response\n{\n  \"latitude\": 45.4215,\n  \"longitude\": -75.6972,\n  \"confidence\": 95,\n  \"provider\": \"GOOGLE\",\n  \"formattedAddress\": \"123 Main St, Ottawa, ON K1A 0B1, Canada\"\n}\n

Bulk Geocode Job:

POST /api/map/locations/bulk-geocode/start\n{\n  \"confidenceThreshold\": 70,\n  \"provider\": \"GOOGLE\",\n  \"batchSize\": 50\n}\n\n// Response\n{\n  \"jobId\": \"bulk-geocode-uuid\",\n  \"status\": \"queued\",\n  \"totalLocations\": 1234\n}\n
"},{"location":"v2/features/map/geocoding/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/geocoding/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description GEOCODING_ENABLED boolean true Enable geocoding services GEOCODING_CACHE_ENABLED boolean true Cache results in Redis GEOCODING_CACHE_TTL_HOURS number 168 Cache TTL (7 days) GEOCODING_PROVIDERS string GOOGLE,MAPBOX,NOMINATIM,PHOTON,LOCATIONIQ,ARCGIS Provider order (comma-separated) GOOGLE_MAPS_API_KEY string - Google Geocoding API key (required if Google enabled) MAPBOX_ACCESS_TOKEN string - Mapbox API token (required if Mapbox enabled) LOCATIONIQ_API_KEY string - LocationIQ API key (required if LocationIQ enabled) NOMINATIM_BASE_URL string https://nominatim.openstreetmap.org Nominatim API URL PHOTON_BASE_URL string https://photon.komoot.io Photon API URL ARCGIS_BASE_URL string https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer ArcGIS API URL"},{"location":"v2/features/map/geocoding/#provider-configuration","title":"Provider Configuration","text":"

Provider Selection Strategy:

  1. Free tier exhausted? Remove provider from chain
  2. Rate limit hit? Skip provider temporarily (5min cooldown)
  3. Service down? Skip provider (exponential backoff)
  4. Low confidence? Try next provider

Provider Priority (Default):

  1. Google \u2014 Best accuracy, paid API (free $200/month credit)
  2. Mapbox \u2014 Good accuracy, generous free tier (100k/month)
  3. Nominatim \u2014 Free, moderate accuracy, 1 req/sec limit
  4. Photon \u2014 Free, fast, good for European addresses
  5. LocationIQ \u2014 Free tier (5k/day), good international coverage
  6. ArcGIS \u2014 Free tier (20k/month), good US coverage
"},{"location":"v2/features/map/geocoding/#confidence-scoring-rules","title":"Confidence Scoring Rules","text":"

Confidence Score Calculation:

Match Type Google Mapbox Nominatim Photon LocationIQ ArcGIS Rooftop (exact address) 95-100 95-100 90-95 90-95 90-95 95-100 Interpolated 85-94 85-94 80-89 80-89 80-89 85-94 Street-level 70-84 70-84 65-79 65-79 65-79 70-84 Postal code 50-69 50-69 45-64 45-64 45-64 50-69 City 30-49 30-49 25-44 25-44 25-44 30-49 Province/State 10-29 10-29 5-24 5-24 5-24 10-29 Country 0-9 0-9 0-4 0-4 0-4 0-9

Confidence Thresholds:

"},{"location":"v2/features/map/geocoding/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/map/geocoding/#single-address-geocoding","title":"Single Address Geocoding","text":"

Step 1: Enter Address

On LocationsPage create/edit form, enter address:

Address: 123 Main Street\nPostal Code: K1A 0B1\n

Step 2: Click Geocode Button

Click Geocode button below address field.

Step 3: View Results

System displays:

Step 4: Save Location

Click Save to create/update location with geocoded coordinates.

"},{"location":"v2/features/map/geocoding/#bulk-re-geocoding","title":"Bulk Re-Geocoding","text":"

Use Case: Re-geocode locations with missing or low-confidence coordinates.

Step 1: Open Bulk Geocode Modal

On LocationsPage, click Bulk Re-Geocode button.

Step 2: Configure Job

Set parameters:

Step 3: Start Job

Click Start Job to queue job in BullMQ.

Step 4: Monitor Progress

View real-time progress:

Step 5: Review Results

After job completes:

Step 6: Retry Failures (Optional)

For failed addresses:

  1. Download failure CSV
  2. Manually verify addresses
  3. Fix typos/formatting issues
  4. Re-import CSV
  5. Run bulk geocode again
"},{"location":"v2/features/map/geocoding/#reverse-geocoding","title":"Reverse Geocoding","text":"

Use Case: Convert map click coordinates to address.

Step 1: Click Map

On AdminMapView, click location to get lat/lng.

Step 2: Reverse Geocode

Click Reverse Geocode button in popup.

Step 3: View Address

System displays:

Address: 123 Main St\nCity: Ottawa\nProvince: ON\nCountry: Canada\n

Step 4: Create Location

Click Create Location to auto-fill address form.

"},{"location":"v2/features/map/geocoding/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/geocoding/#geocoding-service-backend","title":"Geocoding Service (Backend)","text":"
// api/src/modules/map/geocoding/geocoding.service.ts\nexport interface GeocodeResult {\n  latitude: number;\n  longitude: number;\n  confidence: number;\n  provider: GeocodeProvider;\n  formattedAddress?: string;\n}\n\nasync function geocode(address: string): Promise<GeocodeResult> {\n  // Check Redis cache first\n  const cached = await getCachedResult(address);\n  if (cached) {\n    logger.debug('Geocode cache hit', { address });\n    return cached;\n  }\n\n  // Normalize address (expand abbreviations, fix postal code)\n  const normalized = normalizeAddress(address);\n\n  // Try providers in order\n  const providers = env.GEOCODING_PROVIDERS.split(',');\n  let lastError: Error | null = null;\n\n  for (const providerName of providers) {\n    try {\n      const result = await tryProvider(providerName, normalized);\n\n      if (result.confidence >= 50) {\n        // Cache successful result\n        await setCachedResult(address, result);\n        logger.info('Geocoded address', {\n          address,\n          provider: result.provider,\n          confidence: result.confidence,\n        });\n        return result;\n      }\n    } catch (err) {\n      lastError = err as Error;\n      logger.warn(`Provider ${providerName} failed`, { address, error: err });\n      continue;\n    }\n  }\n\n  throw new AppError(\n    500,\n    'All geocoding providers failed',\n    'GEOCODING_FAILED',\n    { address, lastError: lastError?.message }\n  );\n}\n
"},{"location":"v2/features/map/geocoding/#provider-chain-implementation","title":"Provider Chain Implementation","text":"
// api/src/modules/map/geocoding/geocoding.service.ts\nasync function tryProvider(\n  providerName: string,\n  address: string\n): Promise<GeocodeResult> {\n  switch (providerName.toUpperCase()) {\n    case 'GOOGLE':\n      return await geocodeWithGoogle(address);\n    case 'MAPBOX':\n      return await geocodeWithMapbox(address);\n    case 'NOMINATIM':\n      return await geocodeWithNominatim(address);\n    case 'PHOTON':\n      return await geocodeWithPhoton(address);\n    case 'LOCATIONIQ':\n      return await geocodeWithLocationIQ(address);\n    case 'ARCGIS':\n      return await geocodeWithArcGIS(address);\n    default:\n      throw new Error(`Unknown provider: ${providerName}`);\n  }\n}\n
"},{"location":"v2/features/map/geocoding/#google-geocoding-provider","title":"Google Geocoding Provider","text":"
// api/src/modules/map/geocoding/geocoding.service.ts\nasync function geocodeWithGoogle(address: string): Promise<GeocodeResult> {\n  if (!env.GOOGLE_MAPS_API_KEY) {\n    throw new Error('Google Maps API key not configured');\n  }\n\n  const url = new URL('https://maps.googleapis.com/maps/api/geocode/json');\n  url.searchParams.set('address', address);\n  url.searchParams.set('key', env.GOOGLE_MAPS_API_KEY);\n\n  const response = await fetch(url.toString());\n  const data = await response.json();\n\n  if (data.status !== 'OK' || !data.results?.[0]) {\n    throw new Error(`Google geocoding failed: ${data.status}`);\n  }\n\n  const result = data.results[0];\n  const location = result.geometry.location;\n\n  // Calculate confidence based on location_type\n  let confidence = 50;\n  if (result.geometry.location_type === 'ROOFTOP') {\n    confidence = 95;\n  } else if (result.geometry.location_type === 'RANGE_INTERPOLATED') {\n    confidence = 85;\n  } else if (result.geometry.location_type === 'GEOMETRIC_CENTER') {\n    confidence = 70;\n  }\n\n  return {\n    latitude: location.lat,\n    longitude: location.lng,\n    confidence,\n    provider: GeocodeProvider.GOOGLE,\n    formattedAddress: result.formatted_address,\n  };\n}\n
"},{"location":"v2/features/map/geocoding/#mapbox-geocoding-provider","title":"Mapbox Geocoding Provider","text":"
// api/src/modules/map/geocoding/geocoding.service.ts\nasync function geocodeWithMapbox(address: string): Promise<GeocodeResult> {\n  if (!env.MAPBOX_ACCESS_TOKEN) {\n    throw new Error('Mapbox access token not configured');\n  }\n\n  const encodedAddress = encodeURIComponent(address);\n  const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodedAddress}.json?access_token=${env.MAPBOX_ACCESS_TOKEN}`;\n\n  const response = await fetch(url);\n  const data = await response.json();\n\n  if (!data.features?.[0]) {\n    throw new Error('Mapbox geocoding failed: no results');\n  }\n\n  const feature = data.features[0];\n  const [lng, lat] = feature.center;\n\n  // Calculate confidence based on place_type\n  let confidence = 50;\n  if (feature.place_type.includes('address')) {\n    confidence = 95;\n  } else if (feature.place_type.includes('place')) {\n    confidence = 60;\n  } else if (feature.place_type.includes('postcode')) {\n    confidence = 55;\n  }\n\n  // Boost confidence for exact match\n  if (feature.relevance >= 0.9) {\n    confidence = Math.min(100, confidence + 10);\n  }\n\n  return {\n    latitude: lat,\n    longitude: lng,\n    confidence,\n    provider: GeocodeProvider.MAPBOX,\n    formattedAddress: feature.place_name,\n  };\n}\n
"},{"location":"v2/features/map/geocoding/#nominatim-geocoding-provider","title":"Nominatim Geocoding Provider","text":"
// api/src/modules/map/geocoding/geocoding.service.ts\nasync function geocodeWithNominatim(address: string): Promise<GeocodeResult> {\n  const baseUrl = env.NOMINATIM_BASE_URL || 'https://nominatim.openstreetmap.org';\n  const url = new URL(`${baseUrl}/search`);\n  url.searchParams.set('q', address);\n  url.searchParams.set('format', 'json');\n  url.searchParams.set('limit', '1');\n\n  const response = await fetch(url.toString(), {\n    headers: { 'User-Agent': 'Changemaker Lite/2.0' }, // Required by Nominatim\n  });\n\n  const data = await response.json();\n\n  if (!data?.[0]) {\n    throw new Error('Nominatim geocoding failed: no results');\n  }\n\n  const result = data[0];\n  const lat = parseFloat(result.lat);\n  const lng = parseFloat(result.lon);\n\n  // Calculate confidence based on osm_type and importance\n  let confidence = 50;\n  if (result.osm_type === 'node' && result.importance > 0.5) {\n    confidence = 90;\n  } else if (result.osm_type === 'way' && result.importance > 0.4) {\n    confidence = 80;\n  } else if (result.importance > 0.3) {\n    confidence = 70;\n  }\n\n  return {\n    latitude: lat,\n    longitude: lng,\n    confidence,\n    provider: GeocodeProvider.NOMINATIM,\n    formattedAddress: result.display_name,\n  };\n}\n
"},{"location":"v2/features/map/geocoding/#address-normalization","title":"Address Normalization","text":"
// api/src/modules/map/geocoding/geocoding.service.ts\nconst abbreviations: Record<string, string> = {\n  // Street types\n  'st': 'street',\n  'ave': 'avenue',\n  'blvd': 'boulevard',\n  'dr': 'drive',\n  'rd': 'road',\n  'ln': 'lane',\n  'ct': 'court',\n  // Directional suffixes\n  'n': 'north',\n  'ne': 'northeast',\n  'e': 'east',\n  'se': 'southeast',\n  's': 'south',\n  'sw': 'southwest',\n  'w': 'west',\n  'nw': 'northwest',\n};\n\nfunction normalizeAddress(address: string): string {\n  let normalized = address.trim().toLowerCase();\n\n  // Expand abbreviations\n  for (const [abbr, full] of Object.entries(abbreviations)) {\n    const regex = new RegExp(`\\\\b${abbr}\\\\b`, 'gi');\n    normalized = normalized.replace(regex, full);\n  }\n\n  // Normalize postal code (K1A0B1 \u2192 K1A 0B1)\n  normalized = normalized.replace(\n    /\\b([A-Za-z]\\d[A-Za-z])\\s*(\\d[A-Za-z]\\d)\\b/g,\n    (match, p1, p2) => `${p1.toUpperCase()} ${p2.toUpperCase()}`\n  );\n\n  // Remove extra whitespace\n  normalized = normalized.replace(/\\s+/g, ' ').trim();\n\n  return normalized;\n}\n
"},{"location":"v2/features/map/geocoding/#redis-caching","title":"Redis Caching","text":"
// api/src/modules/map/geocoding/geocoding.service.ts\nimport crypto from 'crypto';\n\nconst CACHE_KEY_PREFIX = 'GEOCODE_CACHE:';\n\nfunction hashAddress(address: string): string {\n  return crypto.createHash('sha256').update(address).digest('hex').substring(0, 16);\n}\n\nasync function getCachedResult(address: string): Promise<GeocodeResult | null> {\n  if (env.GEOCODING_CACHE_ENABLED !== 'true') return null;\n\n  try {\n    const key = `${CACHE_KEY_PREFIX}${hashAddress(address)}`;\n    const cached = await redis.get(key);\n\n    if (!cached) {\n      cm_geocode_cache_misses.inc();\n      return null;\n    }\n\n    const parsed = JSON.parse(cached);\n    cm_geocode_cache_hits.inc();\n    return parsed;\n  } catch (err) {\n    logger.warn('Failed to get cached geocode result:', err);\n    return null;\n  }\n}\n\nasync function setCachedResult(address: string, result: GeocodeResult): Promise<void> {\n  if (env.GEOCODING_CACHE_ENABLED !== 'true') return;\n\n  try {\n    const key = `${CACHE_KEY_PREFIX}${hashAddress(address)}`;\n    const ttlSeconds = env.GEOCODING_CACHE_TTL_HOURS * 60 * 60;\n\n    await redis.setex(key, ttlSeconds, JSON.stringify(result));\n  } catch (err) {\n    logger.warn('Failed to cache geocode result:', err);\n  }\n}\n
"},{"location":"v2/features/map/geocoding/#bulk-geocoding-job-bullmq","title":"Bulk Geocoding Job (BullMQ)","text":"
// api/src/services/geocode-queue.service.ts\nimport Bull from 'bull';\n\nexport const geocodeQueue = new Bull('geocode-queue', env.REDIS_URL, {\n  defaultJobOptions: {\n    attempts: 3,\n    backoff: { type: 'exponential', delay: 5000 },\n    removeOnComplete: 100,\n    removeOnFail: false,\n  },\n});\n\n// Bulk geocode job processor\ngeocodeQueue.process(async (job) => {\n  const { locationIds, provider, batchSize } = job.data;\n\n  logger.info('Processing bulk geocode job', {\n    jobId: job.id,\n    totalLocations: locationIds.length,\n  });\n\n  let completed = 0;\n  let failed = 0;\n\n  for (let i = 0; i < locationIds.length; i += batchSize) {\n    const batch = locationIds.slice(i, i + batchSize);\n\n    for (const locationId of batch) {\n      try {\n        const location = await prisma.location.findUnique({\n          where: { id: locationId },\n        });\n\n        if (!location?.address) {\n          failed++;\n          continue;\n        }\n\n        const result = await geocodingService.geocode(location.address);\n\n        await prisma.location.update({\n          where: { id: locationId },\n          data: {\n            latitude: result.latitude,\n            longitude: result.longitude,\n            geocodeConfidence: result.confidence,\n            geocodeProvider: result.provider,\n            lastGeocodeAttempt: new Date(),\n          },\n        });\n\n        completed++;\n      } catch (err) {\n        logger.warn('Failed to geocode location', { locationId, error: err });\n        failed++;\n      }\n    }\n\n    // Update job progress\n    const progress = ((i + batch.length) / locationIds.length) * 100;\n    await job.progress(progress);\n\n    // Rate limiting: wait 1s between batches\n    await new Promise((resolve) => setTimeout(resolve, 1000));\n  }\n\n  return { completed, failed, total: locationIds.length };\n});\n
"},{"location":"v2/features/map/geocoding/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/geocoding/#issue-all-providers-failing","title":"Issue: All Providers Failing","text":"

Symptoms:

Causes:

Solutions:

  1. Verify API keys:
# Check .env file\ngrep \"GOOGLE_MAPS_API_KEY\\|MAPBOX_ACCESS_TOKEN\\|LOCATIONIQ_API_KEY\" .env\n\n# Test Google API key directly\ncurl \"https://maps.googleapis.com/maps/api/geocode/json?address=123+Main+St&key=YOUR_KEY\"\n
  1. Check provider health:
# View Prometheus metrics\ncurl http://localhost:4000/metrics | grep cm_geocode\n\n# View API logs\ndocker compose logs -f api | grep geocode\n
  1. Test with free provider (Nominatim):
# Temporarily use only Nominatim\nGEOCODING_PROVIDERS=NOMINATIM\n\n# Test endpoint\ncurl -X POST http://localhost:4000/api/map/locations/geocode \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"address\":\"123 Main Street, Ottawa, ON\"}'\n
"},{"location":"v2/features/map/geocoding/#issue-low-confidence-scores","title":"Issue: Low Confidence Scores","text":"

Symptoms:

Causes:

Solutions:

  1. Improve address format:
// Bad: missing postal code, street type\n\"123 Main, Ottawa\"\n\n// Good: full Canadian address\n\"123 Main Street, Ottawa, ON K1A 0B1\"\n
  1. Try different providers:
# Google/Mapbox best for North American addresses\nGEOCODING_PROVIDERS=GOOGLE,MAPBOX,NOMINATIM\n\n# Nominatim/Photon better for European addresses\nGEOCODING_PROVIDERS=NOMINATIM,PHOTON,MAPBOX\n
  1. Manual verification:

For critical addresses, manually verify coordinates:

# Reverse geocode to check accuracy\ncurl -X POST http://localhost:4000/api/map/locations/reverse-geocode \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"latitude\":45.4215,\"longitude\":-75.6972}'\n
"},{"location":"v2/features/map/geocoding/#issue-bulk-geocoding-job-stuck","title":"Issue: Bulk Geocoding Job Stuck","text":"

Symptoms:

Causes:

Solutions:

  1. Check job status:
# View BullMQ jobs in Redis\ndocker compose exec redis redis-cli KEYS \"bull:geocode-queue:*\"\n\n# Get job details\ndocker compose exec redis redis-cli GET \"bull:geocode-queue:JOB_ID\"\n
  1. Restart worker:
# Restart API service (worker runs in API container)\ndocker compose restart api\n
  1. Cancel stuck job:
# Via API endpoint\ncurl -X POST http://localhost:4000/api/map/locations/bulk-geocode/cancel \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Or manually in Redis\ndocker compose exec redis redis-cli DEL \"bull:geocode-queue:ACTIVE_JOB_ID\"\n
  1. Increase timeout:
// api/src/services/geocode-queue.service.ts\ndefaultJobOptions: {\n  timeout: 3600000, // 1 hour (was 30min)\n}\n
"},{"location":"v2/features/map/geocoding/#issue-cache-not-working","title":"Issue: Cache Not Working","text":"

Symptoms:

Causes:

Solutions:

  1. Verify Redis connection:
# Check Redis is running\ndocker compose ps redis\n\n# Test Redis connection from API\ndocker compose exec api node -e \"const redis = require('./src/config/redis').redis; redis.ping().then(console.log);\"\n
  1. Check cache keys:
# View cached geocode results\ndocker compose exec redis redis-cli KEYS \"GEOCODE_CACHE:*\"\n\n# Get sample cached result\ndocker compose exec redis redis-cli GET \"GEOCODE_CACHE:abc123def456\"\n
  1. Enable caching:
# Verify in .env\nGEOCODING_CACHE_ENABLED=true\nGEOCODING_CACHE_TTL_HOURS=168  # 7 days\n
  1. Clear cache to test:
# Delete all geocode cache keys\ndocker compose exec redis redis-cli --scan --pattern \"GEOCODE_CACHE:*\" | xargs docker compose exec redis redis-cli DEL\n
"},{"location":"v2/features/map/geocoding/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/geocoding/#provider-rate-limits","title":"Provider Rate Limits","text":"

Free Tier Limits:

Provider Free Tier Rate Limit Best For Google $200/month credit (~28k reqs) 50 req/sec North American addresses Mapbox 100,000/month 600 req/min Global coverage Nominatim Unlimited 1 req/sec Europe, low-volume Photon Unlimited No limit* Europe, high-volume LocationIQ 5,000/day 2 req/sec Testing, low-volume ArcGIS 20,000/month 50 req/sec US addresses

*Self-hosted Photon recommended for production high-volume use.

Best Practices:

  1. Enable Redis caching (7-day TTL reduces API calls by ~80%)
  2. Use bulk geocoding jobs (BullMQ queue with 1s delay between batches)
  3. Prefer NAR imports (coordinates included, no geocoding needed)
  4. Set up Photon self-hosted (for high-volume European campaigns)
"},{"location":"v2/features/map/geocoding/#caching-strategy","title":"Caching Strategy","text":"

Cache Hit Rate Optimization:

// Normalize address before hashing to improve cache hits\nfunction hashAddress(address: string): string {\n  // Remove punctuation, lowercase, trim\n  const normalized = address\n    .toLowerCase()\n    .replace(/[.,]/g, '')\n    .replace(/\\s+/g, ' ')\n    .trim();\n\n  return crypto.createHash('sha256').update(normalized).digest('hex').substring(0, 16);\n}\n

TTL Configuration:

"},{"location":"v2/features/map/geocoding/#bulk-geocoding-performance","title":"Bulk Geocoding Performance","text":"

Batch Size Tuning:

// Small batches: better for rate limits, slower overall\nbatchSize: 10, // 1 req/sec = 10 locations per 10s batch\n\n// Large batches: faster, but may hit rate limits\nbatchSize: 100, // 50 req/sec = 100 locations per 2s batch\n

Optimal Settings:

Provider Batch Size Delay Between Batches Google 50 1s Mapbox 100 10s Nominatim 1 1s (strict rate limit) Photon 50 0s (self-hosted)

Prometheus Metrics:

# Cache hit rate (target: >80%)\nrate(cm_geocode_cache_hits_total[5m]) /\n  (rate(cm_geocode_cache_hits_total[5m]) + rate(cm_geocode_cache_misses_total[5m]))\n\n# Provider success rate (target: >95%)\nsum by (provider) (rate(cm_geocode_success_total[5m]))\n
"},{"location":"v2/features/map/geocoding/#related-documentation","title":"Related Documentation","text":"

Backend Modules:

Frontend Pages:

Database:

Features:

Configuration:

"},{"location":"v2/features/map/locations/","title":"Location Management System","text":""},{"location":"v2/features/map/locations/#overview","title":"Overview","text":"

The location management system is the foundation of Changemaker Lite's field organizing capabilities. It provides building-level and unit-level voter/supporter tracking with comprehensive address management, geocoding integration, and Canadian electoral data (NAR) import support.

Key Capabilities:

Use Cases:

"},{"location":"v2/features/map/locations/#architecture","title":"Architecture","text":"
graph TD\n    A[Admin User] -->|Manages Locations| B[LocationsPage]\n    B -->|CRUD Operations| C[Locations API]\n    C -->|Save/Query| D[(Location Model)]\n    C -->|Geocode Address| E[Geocoding Service]\n    E -->|Try Providers| F[Multi-Provider Chain]\n    F -->|Cache Result| G[(Redis Cache)]\n\n    H[CSV Import] -->|Parse File| C\n    C -->|Validate| I[Location Service]\n    I -->|Auto-Geocode| E\n    I -->|Create Records| D\n\n    J[NAR Import] -->|Server Stream| K[NAR Import Service]\n    K -->|Join Address+Location| L[Location Files]\n    K -->|Convert Coords| M[proj4 Lambert\u2192WGS84]\n    K -->|Filter| N[Cut/City/Postal]\n    K -->|Bulk Insert| D\n\n    D -->|1:N| O[(Address Model)]\n    D -->|Assigned To| P[(Cut Model)]\n\n    Q[Public Map] -->|GET /api/public/map/locations| C\n    C -->|Filter by Bounds| D\n\n    R[Canvass Session] -->|Load Addresses| C\n    C -->|Point-in-Polygon| S[Spatial Utils]\n\n    style D fill:#e1f5ff\n    style O fill:#e1f5ff\n    style P fill:#e1f5ff\n    style G fill:#fff4e1

Flow Description:

  1. Admin creates location \u2192 Location service validates address and optionally geocodes
  2. CSV import \u2192 Service parses file, detects format (standard/NAR), geocodes if needed, creates records
  3. NAR server import \u2192 Streams large files, joins Address+Location CSVs, converts Lambert coords, filters, bulk inserts
  4. Public map loads \u2192 Location service queries by bounds, returns color-coded markers
  5. Canvass session starts \u2192 Service loads addresses within cut polygon using ray-casting algorithm
  6. Geocoding \u2192 Multi-provider chain tries providers in order, caches successful results
"},{"location":"v2/features/map/locations/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/locations/#location-model","title":"Location Model","text":"

See Location Model Documentation for full schema.

Key Fields:

NAR-Specific Fields:

Geocoding Fields:

"},{"location":"v2/features/map/locations/#address-model","title":"Address Model","text":"

See Address Model Documentation for full schema.

Key Fields:

NAR-Specific Fields:

Related Models:

"},{"location":"v2/features/map/locations/#api-endpoints","title":"API Endpoints","text":"

See Locations Backend Module Documentation for full API reference.

Admin Endpoints:

Method Endpoint Auth Description GET /api/map/locations MAP_ADMIN List locations with pagination, search, filters GET /api/map/locations/stats MAP_ADMIN Get location statistics (total, geocoded, by confidence) GET /api/map/locations/:id MAP_ADMIN Get location details with addresses POST /api/map/locations MAP_ADMIN Create new location PATCH /api/map/locations/:id MAP_ADMIN Update location DELETE /api/map/locations/:id MAP_ADMIN Delete location (and cascade addresses) POST /api/map/locations/geocode MAP_ADMIN Geocode single address POST /api/map/locations/reverse-geocode MAP_ADMIN Reverse geocode lat/lng to address POST /api/map/locations/import MAP_ADMIN Import CSV file (standard or NAR format) GET /api/map/locations/export MAP_ADMIN Export locations to CSV GET /api/map/locations/:id/history MAP_ADMIN Get location change history

Bulk Operations:

Method Endpoint Auth Description POST /api/map/locations/bulk-geocode/start MAP_ADMIN Start bulk geocoding job (BullMQ) GET /api/map/locations/bulk-geocode/status MAP_ADMIN Check bulk geocoding job status POST /api/map/locations/bulk-geocode/cancel MAP_ADMIN Cancel running bulk geocoding job

NAR Import Endpoints:

Method Endpoint Auth Description GET /api/map/locations/nar/datasets MAP_ADMIN List available NAR datasets from /data directory POST /api/map/locations/nar/import MAP_ADMIN Server-side streaming NAR import with filters GET /api/map/locations/nar/import/progress MAP_ADMIN Get NAR import progress (polling endpoint)

Public Endpoints:

Method Endpoint Auth Description GET /api/public/map/locations None List locations by bounds (for public map)

Volunteer Endpoints:

Method Endpoint Auth Description PATCH /api/map/canvass/volunteer/locations/:id Any logged-in user Update location from canvass session"},{"location":"v2/features/map/locations/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/locations/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description GEOCODING_ENABLED boolean true Enable geocoding services GEOCODING_CACHE_ENABLED boolean true Cache geocoding results in Redis GEOCODING_CACHE_TTL_HOURS number 168 Cache TTL (7 days) GEOCODING_PROVIDERS string[] See geocoding.md Comma-separated provider list GOOGLE_MAPS_API_KEY string - Google Geocoding API key MAPBOX_ACCESS_TOKEN string - Mapbox API token LOCATIONIQ_API_KEY string - LocationIQ API key NAR_DATA_DIR string /data Directory containing NAR CSV files"},{"location":"v2/features/map/locations/#database-indexes","title":"Database Indexes","text":"

Key indexes for performance:

-- Location queries\nCREATE INDEX idx_locations_lat_lng ON \"Location\" (latitude, longitude);\nCREATE INDEX idx_locations_postal_code ON \"Location\" (\"postalCode\");\nCREATE INDEX idx_locations_province ON \"Location\" (province);\nCREATE INDEX idx_locations_federal_district ON \"Location\" (\"federalDistrict\");\nCREATE INDEX idx_locations_geocode_confidence ON \"Location\" (\"geocodeConfidence\");\nCREATE INDEX idx_locations_nar_loc_guid ON \"Location\" (\"narLocGuid\");\n\n-- Address queries\nCREATE INDEX idx_addresses_location_id ON \"Address\" (\"locationId\");\nCREATE INDEX idx_addresses_support_level ON \"Address\" (\"supportLevel\");\nCREATE INDEX idx_addresses_nar_addr_guid ON \"Address\" (\"narAddrGuid\");\n\n-- Spatial queries (cut assignment)\nCREATE INDEX idx_locations_lat ON \"Location\" (latitude);\nCREATE INDEX idx_locations_lng ON \"Location\" (longitude);\n
"},{"location":"v2/features/map/locations/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/map/locations/#creating-a-location","title":"Creating a Location","text":"

Step 1: Navigate to Locations Page

Navigate to Map \u2192 Locations in the admin sidebar.

![LocationsPage Screenshot Placeholder]

Step 2: Click \"Add Location\"

Click the + Add Location button in the top-right corner.

Step 3: Enter Address Information

Fill in the location form:

Step 4: Auto-Geocode (Optional)

Click Geocode button to automatically fetch latitude/longitude coordinates. The system will:

  1. Try geocoding providers in order (Google \u2192 Mapbox \u2192 Nominatim \u2192 Photon \u2192 LocationIQ \u2192 ArcGIS)
  2. Return confidence score (0-100)
  3. Display formatted address from provider
  4. Cache result in Redis for 7 days

Step 5: Add Addresses (Units)

For multi-unit buildings, click Add Address to create unit records:

Step 6: Save Location

Click Create to save the location and addresses.

"},{"location":"v2/features/map/locations/#csv-import-workflow","title":"CSV Import Workflow","text":"

Step 1: Prepare CSV File

Prepare a CSV file with the following columns (flexible header names):

Standard Format:

address,firstName,lastName,email,phone,unitNumber,supportLevel,sign,notes,latitude,longitude\n123 Main St,John,Doe,john@example.com,555-1234,101,LEVEL_1,true,Friendly contact,,\n124 Main St,Jane,Smith,jane@example.com,555-5678,,LEVEL_2,false,Ask about lawn sign,45.4215,-75.6972\n

NAR Format (auto-detected if 3+ NAR columns present):

CIVIC_NO,OFFICIAL_STREET_NAME,OFFICIAL_STREET_TYPE,APT_NO_LABEL,MAIL_POSTAL_CODE,BG_LATITUDE,BG_LONGITUDE,FED_ENG_NAME\n123,Main,Street,101,K1A 0B1,45.4215,-75.6972,Ottawa Centre\n124,Main,Street,,K1A 0B2,45.4220,-75.6975,Ottawa Centre\n

Step 2: Open Import Modal

Click Import CSV button on LocationsPage.

Step 3: Select Import Format

Choose format:

Step 4: Configure Filters (Optional)

Filter imported locations:

Step 5: Upload File

Drag-and-drop or click to select CSV file.

Step 6: Configure Geocoding

Toggle Geocode Missing Coordinates:

Step 7: Review Import Results

After import completes, view results:

"},{"location":"v2/features/map/locations/#nar-server-import-workflow","title":"NAR Server Import Workflow","text":"

For large NAR datasets (>100MB), use server-side streaming import:

Step 1: Upload NAR Files to Server

Copy NAR CSV files to server's /data directory:

# Example NAR files for Ontario (province code 35)\n/data/Address_35_part_1.csv\n/data/Address_35_part_2.csv\n/data/Location_35.csv\n

Step 2: Open NAR Import Tab

Click NAR Import tab on LocationsPage.

Step 3: Scan for Datasets

Click Scan NAR Directory to detect available datasets. The system will:

Step 4: Select Province

Choose province from dropdown (e.g., \"35 - Ontario (10.5 GB, 45 files)\").

Step 5: Configure Filters

Apply optional filters:

Step 6: Start Import

Click Start Import. The system will:

  1. Stream Address CSV files (multi-part files processed sequentially)
  2. Join with Location CSV on LOC_GUID
  3. Convert BG_X/BG_Y (Lambert projection) to lat/lng (WGS84) using proj4
  4. Apply filters (city, postal, cut, residential)
  5. Bulk insert locations + addresses (transaction batches of 500)
  6. Update progress every 5 seconds

Step 7: Monitor Progress

View real-time progress:

Step 8: Review Results

After import completes:

"},{"location":"v2/features/map/locations/#bulk-re-geocoding","title":"Bulk Re-Geocoding","text":"

For locations with missing or low-confidence coordinates:

Step 1: Open Bulk Geocode Modal

Click Bulk Re-Geocode button on LocationsPage.

Step 2: Configure Job Parameters

Set parameters:

Step 3: Start Job

Click Start Job to queue bulk geocoding job in BullMQ.

Step 4: Monitor Progress

Poll job status:

Step 5: Cancel Job (Optional)

Click Cancel Job to stop bulk geocoding.

"},{"location":"v2/features/map/locations/#exporting-locations","title":"Exporting Locations","text":"

Step 1: Configure Export Filters

Apply filters on LocationsPage:

Step 2: Click Export CSV

Click Export CSV button. The system will:

  1. Export locations matching current filters
  2. Include all address records (one row per address)
  3. Download CSV file with timestamp

Export Format:

locationId,address,latitude,longitude,postalCode,province,federalDistrict,buildingType,totalUnits,geocodeConfidence,geocodeProvider,unitNumber,firstName,lastName,email,phone,supportLevel,sign,signSize,notes\nuuid-1,123 Main St,45.4215,-75.6972,K1A 0B1,ON,Ottawa Centre,MULTI_UNIT,12,95,GOOGLE,101,John,Doe,john@example.com,555-1234,LEVEL_1,true,24x18 lawn,Friendly contact\n
"},{"location":"v2/features/map/locations/#public-workflow","title":"Public Workflow","text":"

Public users can view locations on the interactive map.

Step 1: Navigate to Public Map

Visit /map (public route, no authentication required).

Step 2: Browse Map

Interact with Leaflet map:

Step 3: View Cut Overlays

Toggle cut overlays using Cuts control panel:

Step 4: Geolocate

Click Geolocate button to center map on current location (requires browser geolocation permission).

Step 5: Fullscreen Mode

Click Fullscreen button to expand map to full screen.

"},{"location":"v2/features/map/locations/#volunteer-workflow","title":"Volunteer Workflow","text":"

Volunteers can update location data during canvassing sessions.

Step 1: Start Canvass Session

See Canvassing Documentation for full workflow.

Step 2: Record Visit

When visiting a location, update fields:

Step 3: Update Location

Click Save Visit to record changes. The system will:

  1. Create CanvassVisit record with outcome
  2. Update Address with new supportLevel/sign/notes
  3. Update Location.lastUpdated timestamp
  4. Create LocationHistory audit record
"},{"location":"v2/features/map/locations/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/locations/#creating-a-location-frontend","title":"Creating a Location (Frontend)","text":"
// admin/src/pages/LocationsPage.tsx\nconst handleCreate = async (values: any) => {\n  try {\n    const { data } = await api.post<Location>('/map/locations', {\n      address: values.address,\n      postalCode: values.postalCode,\n      buildingType: values.buildingType,\n      totalUnits: values.totalUnits,\n      buildingNotes: values.buildingNotes,\n      latitude: values.latitude,\n      longitude: values.longitude,\n      geocodeConfidence: values.geocodeConfidence,\n      geocodeProvider: values.geocodeProvider,\n    });\n\n    message.success('Location created');\n    setCreateModalOpen(false);\n    createForm.resetFields();\n    fetchLocations();\n  } catch (error) {\n    message.error('Failed to create location');\n  }\n};\n
"},{"location":"v2/features/map/locations/#geocoding-an-address-frontend","title":"Geocoding an Address (Frontend)","text":"
// admin/src/pages/LocationsPage.tsx\nconst handleGeocode = async () => {\n  const address = createForm.getFieldValue('address');\n  const postalCode = createForm.getFieldValue('postalCode');\n\n  if (!address) {\n    message.warning('Please enter an address first');\n    return;\n  }\n\n  setGeocoding(true);\n  try {\n    const fullAddress = postalCode ? `${address}, ${postalCode}` : address;\n    const { data } = await api.post<GeocodeResult>('/map/locations/geocode', {\n      address: fullAddress,\n    });\n\n    createForm.setFieldsValue({\n      latitude: data.latitude,\n      longitude: data.longitude,\n      geocodeConfidence: data.confidence,\n      geocodeProvider: data.provider,\n    });\n\n    message.success(\n      `Geocoded with ${data.provider} (confidence: ${data.confidence}%)`\n    );\n  } catch (error) {\n    message.error('Geocoding failed');\n  } finally {\n    setGeocoding(false);\n  }\n};\n
"},{"location":"v2/features/map/locations/#location-service-create-backend","title":"Location Service Create (Backend)","text":"
// api/src/modules/map/locations/locations.service.ts\nasync create(data: CreateLocationInput, userId: string) {\n  // Auto-geocode if address provided but no coordinates\n  if (data.address && !data.latitude && !data.longitude) {\n    try {\n      const fullAddress = data.postalCode\n        ? `${data.address}, ${data.postalCode}`\n        : data.address;\n      const geocodeResult = await geocodingService.geocode(fullAddress);\n\n      data.latitude = geocodeResult.latitude;\n      data.longitude = geocodeResult.longitude;\n      data.geocodeConfidence = geocodeResult.confidence;\n      data.geocodeProvider = geocodeResult.provider;\n\n      logger.info('Auto-geocoded location', {\n        address: fullAddress,\n        provider: geocodeResult.provider,\n        confidence: geocodeResult.confidence,\n      });\n    } catch (err) {\n      logger.warn('Auto-geocoding failed, creating location without coordinates', err);\n    }\n  }\n\n  const location = await prisma.location.create({\n    data: {\n      address: data.address,\n      latitude: data.latitude,\n      longitude: data.longitude,\n      postalCode: data.postalCode,\n      province: data.province,\n      federalDistrict: data.federalDistrict,\n      buildingType: data.buildingType,\n      totalUnits: data.totalUnits,\n      buildingNotes: data.buildingNotes,\n      geocodeConfidence: data.geocodeConfidence,\n      geocodeProvider: data.geocodeProvider,\n      createdByUserId: userId,\n    },\n  });\n\n  // Create history record\n  await prisma.locationHistory.create({\n    data: {\n      locationId: location.id,\n      action: LocationHistoryAction.CREATED,\n      changedByUserId: userId,\n      changes: JSON.stringify({ created: true }),\n    },\n  });\n\n  recordLocationQuery('create');\n  return location;\n}\n
"},{"location":"v2/features/map/locations/#csv-import-detection-backend","title":"CSV Import Detection (Backend)","text":"
// api/src/modules/map/locations/locations.service.ts\nfunction detectNarFormat(headers: string[]): boolean {\n  const normalizedHeaders = headers.map((h) => h.trim().toUpperCase());\n  let matchCount = 0;\n  const matched = new Set<string>();\n\n  // NAR columns to detect (need 3+ matches)\n  const NAR_DETECT_COLUMNS = [\n    'CIVIC_NO', 'OFFICIAL_STREET_NAME', 'OFFICIAL_STREET_TYPE',\n    'BG_X', 'BG_Y', 'MAIL_POSTAL_CODE', 'MAIL_PROV_ABVN',\n    'BG_LATITUDE', 'BG_LONGITUDE',\n  ];\n\n  for (const col of NAR_DETECT_COLUMNS) {\n    if (normalizedHeaders.includes(col) && !matched.has(col)) {\n      matched.add(col);\n      matchCount++;\n    }\n  }\n\n  return matchCount >= 3;\n}\n
"},{"location":"v2/features/map/locations/#nar-lambert-coordinate-conversion-backend","title":"NAR Lambert Coordinate Conversion (Backend)","text":"
// api/src/modules/map/locations/locations.service.ts\nimport proj4 from 'proj4';\n\n// Statistics Canada Lambert Conformal Conic (EPSG:3347) \u2192 WGS84 (EPSG:4326)\nproj4.defs(\n  'EPSG:3347',\n  '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=63.390675 +lon_0=-91.86666666666666 ' +\n  '+x_0=6200000 +y_0=3000000 +ellps=GRS80 +units=m +no_defs'\n);\n\n/** Convert BG_X/BG_Y (EPSG:3347 Lambert) to [lat, lng] (WGS84) */\nfunction lambertToLatLng(bgX: number, bgY: number): [number, number] {\n  const [lng, lat] = proj4('EPSG:3347', 'EPSG:4326', [bgX, bgY]);\n  return [lat, lng];\n}\n\n// Usage in NAR import\nconst [lat, lng] = lambertToLatLng(row.BG_X, row.BG_Y);\n
"},{"location":"v2/features/map/locations/#spatial-filtering-by-cut-backend","title":"Spatial Filtering by Cut (Backend)","text":"
// api/src/modules/map/locations/locations.service.ts\nasync findByBounds(filters: BoundsQuery) {\n  const where: Prisma.LocationWhereInput = {\n    latitude: {\n      gte: new Prisma.Decimal(filters.minLat),\n      lte: new Prisma.Decimal(filters.maxLat),\n    },\n    longitude: {\n      gte: new Prisma.Decimal(filters.minLng),\n      lte: new Prisma.Decimal(filters.maxLng),\n    },\n  };\n\n  const locations = await prisma.location.findMany({\n    where,\n    select: {\n      id: true,\n      latitude: true,\n      longitude: true,\n      address: true,\n      addresses: {\n        select: {\n          supportLevel: true,\n        },\n      },\n    },\n  });\n\n  // If cut filter provided, apply point-in-polygon\n  if (filters.cutId) {\n    const cut = await prisma.cut.findUnique({\n      where: { id: filters.cutId },\n      select: { geojson: true },\n    });\n\n    if (cut?.geojson) {\n      const polygons = parseGeoJsonPolygon(cut.geojson);\n      return locations.filter((loc) => {\n        const lat = Number(loc.latitude);\n        const lng = Number(loc.longitude);\n        return polygons.some((poly) => isPointInPolygon(lat, lng, poly));\n      });\n    }\n  }\n\n  return locations;\n}\n
"},{"location":"v2/features/map/locations/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/locations/#issue-geocoding-fails-for-valid-address","title":"Issue: Geocoding Fails for Valid Address","text":"

Symptoms:

Causes:

Solutions:

  1. Check API keys:
# Verify API keys are set in .env\ngrep \"GOOGLE_MAPS_API_KEY\\|MAPBOX_ACCESS_TOKEN\\|LOCATIONIQ_API_KEY\" .env\n
  1. Test geocoding endpoint directly:
curl -X POST http://localhost:4000/api/map/locations/geocode \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"address\":\"123 Main Street, Ottawa, ON K1A 0B1\"}'\n
  1. Check provider order in env:
# Try different provider order\nGEOCODING_PROVIDERS=GOOGLE,NOMINATIM,PHOTON,MAPBOX,LOCATIONIQ,ARCGIS\n
  1. View API logs:
docker compose logs -f api | grep geocode\n
"},{"location":"v2/features/map/locations/#issue-nar-import-fails-or-hangs","title":"Issue: NAR Import Fails or Hangs","text":"

Symptoms:

Causes:

Solutions:

  1. Verify NAR files exist:
# Check /data directory in container\ndocker compose exec api ls -lh /data\n\n# Verify file naming matches NAR format\n# Address_{PROV_CODE}_part_{N}.csv\n# Location_{PROV_CODE}.csv\n
  1. Check province code mapping:
10 = Newfoundland and Labrador\n24 = Quebec\n35 = Ontario\n48 = Alberta\n59 = British Columbia\n62 = Nunavut\n
  1. Test coordinate conversion:
# Verify proj4 is installed\ndocker compose exec api node -e \"const proj4 = require('proj4'); console.log(proj4.version);\"\n
  1. Monitor import progress:
# Watch API logs during import\ndocker compose logs -f api | grep \"NAR import\"\n\n# Check Redis for progress key\ndocker compose exec redis redis-cli GET \"NAR_IMPORT_PROGRESS\"\n
  1. Use smaller filters for testing:

  2. Start with single postal code prefix (e.g., \"K1A\")

  3. Use small cut polygon
  4. Enable residential-only filter (reduces records by ~50%)
"},{"location":"v2/features/map/locations/#issue-duplicate-locations-created-on-import","title":"Issue: Duplicate Locations Created on Import","text":"

Symptoms:

Causes:

Solutions:

  1. Use NAR GUID fields for deduplication:

The system deduplicates by narLocGuid and narAddrGuid:

// Check for existing location before creating\nconst existing = await prisma.location.findFirst({\n  where: { narLocGuid: row.LOC_GUID },\n});\n\nif (existing) {\n  skipped++;\n  continue;\n}\n
  1. Delete duplicates manually:
-- Find duplicate locations by address\nSELECT address, COUNT(*) as count\nFROM \"Location\"\nGROUP BY address\nHAVING COUNT(*) > 1;\n\n-- Keep first, delete rest\nDELETE FROM \"Location\"\nWHERE id NOT IN (\n  SELECT MIN(id)\n  FROM \"Location\"\n  GROUP BY address\n);\n
  1. Use server-side NAR import (better deduplication):

Server-side import joins Address + Location files on LOC_GUID before inserting, preventing duplicates.

"},{"location":"v2/features/map/locations/#issue-low-geocode-confidence-for-nar-data","title":"Issue: Low Geocode Confidence for NAR Data","text":"

Symptoms:

Causes:

Solutions:

  1. Verify coordinate source:

NAR Location files have TWO coordinate fields:

// Priority: use direct WGS84 coords if available\nconst lat = row.BG_LATITUDE\n  ? parseFloat(row.BG_LATITUDE)\n  : (row.BG_X && row.BG_Y ? lambertToLatLng(row.BG_X, row.BG_Y)[0] : null);\n
  1. Re-geocode low-confidence locations:

Use bulk re-geocoding feature with confidence filter <70.

"},{"location":"v2/features/map/locations/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/locations/#query-optimization","title":"Query Optimization","text":"

Bounding Box Queries:

Always use indexed lat/lng queries for map bounds:

-- Efficient: uses idx_locations_lat_lng index\nSELECT * FROM \"Location\"\nWHERE latitude BETWEEN 45.0 AND 46.0\n  AND longitude BETWEEN -76.0 AND -75.0;\n\n-- Inefficient: no index\nSELECT * FROM \"Location\"\nWHERE ST_Contains(polygon, point); -- PostGIS not used\n

Point-in-Polygon:

For small result sets (<1000 locations), use application-level ray-casting:

// api/src/utils/spatial.ts\nexport function isPointInPolygon(\n  lat: number,\n  lng: number,\n  polygonCoords: number[][]\n): boolean {\n  let inside = false;\n  for (let i = 0, j = polygonCoords.length - 1; i < polygonCoords.length; j = i++) {\n    const xi = polygonCoords[i]![1]!; // lat\n    const yi = polygonCoords[i]![0]!; // lng\n    const xj = polygonCoords[j]![1]!;\n    const yj = polygonCoords[j]![0]!;\n\n    const intersect = ((yi > lng) !== (yj > lng)) &&\n      (lat < (xj - xi) * (lng - yi) / (yj - yi) + xi);\n    if (intersect) inside = !inside;\n  }\n  return inside;\n}\n

For large result sets (>10,000 locations), consider PostGIS extension.

"},{"location":"v2/features/map/locations/#geocoding-rate-limits","title":"Geocoding Rate Limits","text":"

Provider Limits:

Provider Free Tier Rate Limit Google $200/month credit 50 req/sec Mapbox 100,000/month 600 req/min Nominatim Unlimited 1 req/sec Photon Unlimited No limit (self-hosted recommended) LocationIQ 5,000/day 2 req/sec ArcGIS 20,000/month 50 req/sec

Best Practices:

  1. Enable Redis caching (default: 7 days TTL)
  2. Use bulk geocoding jobs (BullMQ queue with rate limiting)
  3. Prefer NAR imports (coordinates included, no geocoding needed)
  4. Batch geocoding requests (50 locations per batch)
"},{"location":"v2/features/map/locations/#nar-import-performance","title":"NAR Import Performance","text":"

Large File Streaming:

NAR Address files can be 10+ GB. Use server-side streaming to avoid memory issues:

// api/src/modules/map/locations/nar-import.service.ts\nimport { createReadStream } from 'fs';\nimport { parse } from 'csv-parse';\n\nasync function streamNarFile(filePath: string) {\n  return new Promise((resolve, reject) => {\n    const stream = createReadStream(filePath)\n      .pipe(parse({ columns: true, skip_empty_lines: true }));\n\n    const batch: any[] = [];\n    const BATCH_SIZE = 500;\n\n    stream.on('data', async (row) => {\n      batch.push(row);\n\n      if (batch.length >= BATCH_SIZE) {\n        stream.pause(); // Backpressure\n        await insertBatch(batch);\n        batch.length = 0;\n        stream.resume();\n      }\n    });\n\n    stream.on('end', async () => {\n      if (batch.length > 0) await insertBatch(batch);\n      resolve(true);\n    });\n\n    stream.on('error', reject);\n  });\n}\n

Transaction Batching:

Insert locations in transaction batches to improve performance:

async function insertBatch(rows: any[]) {\n  await prisma.$transaction(\n    rows.map((row) =>\n      prisma.location.create({\n        data: {\n          address: row.address,\n          latitude: row.latitude,\n          longitude: row.longitude,\n          // ... other fields\n        },\n      })\n    ),\n    { timeout: 30000 } // 30s timeout for large batches\n  );\n}\n
"},{"location":"v2/features/map/locations/#map-rendering-performance","title":"Map Rendering Performance","text":"

Marker Clustering:

For maps with >1000 locations, use marker clustering to improve render performance:

// admin/src/components/map/AdminMapView.tsx\nimport MarkerClusterGroup from 'react-leaflet-cluster';\n\n<MarkerClusterGroup>\n  {locations.map((loc) => (\n    <CircleMarker\n      key={loc.id}\n      center={[loc.latitude, loc.longitude]}\n      radius={8}\n      pathOptions={{ color: getSupportLevelColor(loc.supportLevel) }}\n    />\n  ))}\n</MarkerClusterGroup>\n

Viewport Filtering:

Only load locations within map bounds + buffer:

// admin/src/pages/public/MapPage.tsx\nconst handleMapMove = useCallback(\n  debounce(() => {\n    if (!mapRef.current) return;\n\n    const bounds = mapRef.current.getBounds();\n    const buffer = 0.1; // 10% buffer\n\n    fetchLocations({\n      minLat: bounds.getSouth() - buffer,\n      maxLat: bounds.getNorth() + buffer,\n      minLng: bounds.getWest() - buffer,\n      maxLng: bounds.getEast() + buffer,\n    });\n  }, 500),\n  []\n);\n
"},{"location":"v2/features/map/locations/#related-documentation","title":"Related Documentation","text":"

Backend Modules:

Frontend Pages:

Database:

Features:

"},{"location":"v2/features/map/nar-import/","title":"NAR Import System","text":""},{"location":"v2/features/map/nar-import/#overview","title":"Overview","text":"

The National Address Register (NAR) import system enables bulk import of Canadian electoral data from Elections Canada. The system supports the 2025 NAR format with server-side streaming import, coordinate projection conversion, and comprehensive filtering options.

Key Features:

Use Cases:

Architecture Highlights:

"},{"location":"v2/features/map/nar-import/#architecture","title":"Architecture","text":"
flowchart TB\n    subgraph Admin Interface\n        Admin[Admin User]\n        LocationsPage[LocationsPage - NAR Tab]\n    end\n\n    subgraph API Layer\n        DatasetsAPI[\"/api/locations/nar/datasets\"]\n        ImportAPI[\"/api/locations/nar/import\"]\n    end\n\n    subgraph NAR Import Service\n        Scanner[File Scanner]\n        Reader[CSV Stream Reader]\n        Joiner[Address+Location Joiner]\n        Converter[Coordinate Converter]\n        Filter[Filter Pipeline]\n        Importer[Bulk Importer]\n    end\n\n    subgraph File System\n        DataDir[/data/NAR Files]\n        AddressFiles[Address_XX_part_*.csv]\n        LocationFiles[Location_XX.csv]\n    end\n\n    subgraph Database\n        LocationsDB[(Locations)]\n        AddressesDB[(Addresses)]\n    end\n\n    subgraph External Services\n        Proj4[Proj4 Library]\n        EPSG3347[EPSG:3347 Definition]\n    end\n\n    Admin --> LocationsPage\n    LocationsPage --> DatasetsAPI\n    LocationsPage --> ImportAPI\n\n    DatasetsAPI --> Scanner\n    Scanner --> DataDir\n\n    ImportAPI --> Reader\n    Reader --> AddressFiles\n    Reader --> LocationFiles\n\n    Reader --> Joiner\n    Joiner --> Converter\n    Converter --> Proj4\n    Proj4 --> EPSG3347\n\n    Converter --> Filter\n    Filter --> Importer\n    Importer --> LocationsDB\n    Importer --> AddressesDB

Data Flow:

  1. Dataset Discovery:
  2. Scan /data directory for NAR CSV files
  3. Group by province code (10-62)
  4. Identify multi-part Address files
  5. Return available datasets

  6. Import Initiation:

  7. Admin selects province + filters
  8. API creates import job
  9. Begins streaming CSV files

  10. File Processing:

  11. Read Address files (all parts sequentially)
  12. Read Location file (parallel)
  13. Join on LOC_GUID (in-memory map)

  14. Coordinate Conversion:

  15. Extract BG_X/BG_Y from Location file
  16. Convert EPSG:3347 \u2192 WGS84 using Proj4
  17. Fallback to BG_LATITUDE/BG_LONGITUDE if conversion fails

  18. Filtering:

  19. City filter (exact match on MUNICIPALITY)
  20. Postal code filter (prefix match)
  21. Cut filter (point-in-polygon)
  22. Residential filter (BU_USE = 1)

  23. Database Import:

  24. UPSERT Locations by locGuid (prevent duplicates)
  25. INSERT Addresses with foreign key
  26. Batch commits (500 records)
  27. Track progress and errors
"},{"location":"v2/features/map/nar-import/#nar-file-format","title":"NAR File Format","text":""},{"location":"v2/features/map/nar-import/#file-structure","title":"File Structure","text":"

Directory Layout:

/data/\n\u251c\u2500\u2500 Address_10.csv                  # Newfoundland\n\u251c\u2500\u2500 Address_11.csv                  # PEI\n\u251c\u2500\u2500 Address_12.csv                  # Nova Scotia\n\u251c\u2500\u2500 Address_13.csv                  # New Brunswick\n\u251c\u2500\u2500 Address_24_part_1.csv           # Quebec (multi-part)\n\u251c\u2500\u2500 Address_24_part_2.csv\n\u251c\u2500\u2500 Address_24_part_3.csv\n\u251c\u2500\u2500 Address_24_part_4.csv\n\u251c\u2500\u2500 Address_24_part_5.csv\n\u251c\u2500\u2500 Address_24_part_6.csv\n\u251c\u2500\u2500 Address_35_part_1.csv           # Ontario (multi-part)\n\u251c\u2500\u2500 Address_35_part_2.csv\n\u251c\u2500\u2500 ...\n\u251c\u2500\u2500 Location_10.csv\n\u251c\u2500\u2500 Location_11.csv\n\u251c\u2500\u2500 Location_12.csv\n\u251c\u2500\u2500 Location_13.csv\n\u251c\u2500\u2500 Location_24.csv\n\u251c\u2500\u2500 Location_35.csv\n\u2514\u2500\u2500 ...\n

"},{"location":"v2/features/map/nar-import/#address-file-schema","title":"Address File Schema","text":"

File: Address_XX_part_Y.csv

ADDR_GUID,LOC_GUID,CIVIC_NO,OFFICIAL_STREET_NAME,POSTAL_CODE,MUNICIPALITY,PROVINCE_CODE\n{uuid},{uuid},123,MAIN ST,M5H2N2,TORONTO,35\n{uuid},{uuid},125,MAIN ST,M5H2N2,TORONTO,35\n{uuid},{uuid},127,MAIN ST,M5H2N2,TORONTO,35\n

Key Fields:

Field Type Description Example ADDR_GUID UUID Unique address identifier {12345678-...} LOC_GUID UUID Location identifier (FK) {87654321-...} CIVIC_NO String Street number 123, 123A, 123-125 OFFICIAL_STREET_NAME String Street name (uppercase) MAIN ST, YONGE ST POSTAL_CODE String Canadian postal code (no space) M5H2N2, K1A0B1 MUNICIPALITY String City/town name TORONTO, OTTAWA PROVINCE_CODE Integer Province code (10-62) 35 (Ontario)

Record Count: - Small provinces: 10k-50k addresses - Medium provinces: 50k-200k addresses - Large provinces: 200k-1M+ addresses (multi-part files)

"},{"location":"v2/features/map/nar-import/#location-file-schema","title":"Location File Schema","text":"

File: Location_XX.csv

LOC_GUID,BG_LATITUDE,BG_LONGITUDE,BG_X,BG_Y,FED_NUM,BU_USE,MUNICIPALITY\n{uuid},43.6532,-79.3832,1234567.89,234567.89,35001,1,TORONTO\n{uuid},43.6540,-79.3825,1234600.00,234600.00,35001,1,TORONTO\n

Key Fields:

Field Type Description Example LOC_GUID UUID Unique location identifier {87654321-...} BG_LATITUDE Float Latitude (WGS84) 43.6532 BG_LONGITUDE Float Longitude (WGS84) -79.3832 BG_X Float X coord (EPSG:3347 Lambert) 1234567.89 BG_Y Float Y coord (EPSG:3347 Lambert) 234567.89 FED_NUM String Federal electoral district 35001, 24050 BU_USE Integer Building use code 1 = Residential MUNICIPALITY String City/town name TORONTO

Coordinate Systems:

Building Use Codes:

Code Description 1 Residential 2 Commercial 3 Industrial 4 Institutional 5 Parks/Recreation 9 Other"},{"location":"v2/features/map/nar-import/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/nar-import/#location-model-extensions","title":"Location Model Extensions","text":"
model Location {\n  id          Int      @id @default(autoincrement())\n  address     String\n  latitude    Float?\n  longitude   Float?\n  postalCode  String?\n  province    String?\n\n  // NAR-specific fields\n  locGuid           String?  @unique  // NAR LOC_GUID (UUID)\n  federalDistrict   String?           // NAR FED_NUM\n  buildingUse       Int?              // NAR BU_USE code\n  municipality      String?           // NAR MUNICIPALITY\n\n  // Geocoding metadata (populated during import)\n  geocodeConfidence Int?     @default(100)  // NAR = high confidence\n  geocodeProvider   String?  @default(\"NAR\")\n  geocodedAt        DateTime?\n\n  addresses   Address[]\n\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n\n  @@index([locGuid])\n  @@index([federalDistrict])\n  @@index([buildingUse])\n  @@index([postalCode])\n}\n
"},{"location":"v2/features/map/nar-import/#address-model-extensions","title":"Address Model Extensions","text":"
model Address {\n  id         Int      @id @default(autoincrement())\n  locationId Int\n  location   Location @relation(fields: [locationId], references: [id], onDelete: Cascade)\n\n  // NAR-specific fields\n  addrGuid    String?  @unique  // NAR ADDR_GUID (UUID)\n  unitNumber  String?           // NAR CIVIC_NO (if multi-unit)\n\n  // Voter data (future)\n  firstName    String?\n  lastName     String?\n  supportLevel Int?\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([locationId])\n  @@index([addrGuid])\n}\n

UPSERT Strategy:

// Prevent duplicates on re-import\nconst location = await prisma.location.upsert({\n  where: { locGuid: narRecord.LOC_GUID },\n  update: {\n    address: narRecord.addressString,\n    latitude: coords.latitude,\n    longitude: coords.longitude,\n    postalCode: narRecord.POSTAL_CODE,\n    province: provinceMap[narRecord.PROVINCE_CODE],\n    federalDistrict: narRecord.FED_NUM,\n    buildingUse: narRecord.BU_USE,\n    municipality: narRecord.MUNICIPALITY,\n    geocodeProvider: 'NAR',\n    geocodedAt: new Date()\n  },\n  create: {\n    locGuid: narRecord.LOC_GUID,\n    address: narRecord.addressString,\n    latitude: coords.latitude,\n    longitude: coords.longitude,\n    postalCode: narRecord.POSTAL_CODE,\n    province: provinceMap[narRecord.PROVINCE_CODE],\n    federalDistrict: narRecord.FED_NUM,\n    buildingUse: narRecord.BU_USE,\n    municipality: narRecord.MUNICIPALITY,\n    geocodeConfidence: 100,\n    geocodeProvider: 'NAR',\n    geocodedAt: new Date()\n  }\n});\n
"},{"location":"v2/features/map/nar-import/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/map/nar-import/#get-apilocationsnardatasets","title":"GET /api/locations/nar/datasets","text":"

Scan NAR data directory and return available province datasets.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Response:

{\n  \"datasets\": [\n    {\n      \"provinceCode\": \"10\",\n      \"provinceName\": \"Newfoundland and Labrador\",\n      \"addressFiles\": [\"Address_10.csv\"],\n      \"locationFile\": \"Location_10.csv\",\n      \"addressFileCount\": 1,\n      \"estimatedRecords\": 15000,\n      \"lastModified\": \"2025-01-15T00:00:00Z\"\n    },\n    {\n      \"provinceCode\": \"24\",\n      \"provinceName\": \"Quebec\",\n      \"addressFiles\": [\n        \"Address_24_part_1.csv\",\n        \"Address_24_part_2.csv\",\n        \"Address_24_part_3.csv\",\n        \"Address_24_part_4.csv\",\n        \"Address_24_part_5.csv\",\n        \"Address_24_part_6.csv\"\n      ],\n      \"locationFile\": \"Location_24.csv\",\n      \"addressFileCount\": 6,\n      \"estimatedRecords\": 850000,\n      \"lastModified\": \"2025-01-20T00:00:00Z\"\n    },\n    {\n      \"provinceCode\": \"35\",\n      \"provinceName\": \"Ontario\",\n      \"addressFiles\": [\n        \"Address_35_part_1.csv\",\n        \"Address_35_part_2.csv\",\n        \"Address_35_part_3.csv\"\n      ],\n      \"locationFile\": \"Location_35.csv\",\n      \"addressFileCount\": 3,\n      \"estimatedRecords\": 1200000,\n      \"lastModified\": \"2025-01-22T00:00:00Z\"\n    }\n  ],\n  \"dataDir\": \"/data\",\n  \"totalDatasets\": 13\n}\n

Implementation:

// nar-import.service.ts\n\nasync scanDatasets(): Promise<NARDataset[]> {\n  const files = await fs.readdir(NAR_DATA_DIR);\n\n  // Group files by province code\n  const provinceGroups: Record<string, { address: string[], location: string }> = {};\n\n  files.forEach(file => {\n    const addressMatch = file.match(/^Address_(\\d+)(?:_part_\\d+)?\\.csv$/);\n    const locationMatch = file.match(/^Location_(\\d+)\\.csv$/);\n\n    if (addressMatch) {\n      const code = addressMatch[1];\n      if (!provinceGroups[code]) provinceGroups[code] = { address: [], location: '' };\n      provinceGroups[code].address.push(file);\n    } else if (locationMatch) {\n      const code = locationMatch[1];\n      if (!provinceGroups[code]) provinceGroups[code] = { address: [], location: '' };\n      provinceGroups[code].location = file;\n    }\n  });\n\n  // Build dataset objects\n  const datasets: NARDataset[] = [];\n\n  for (const [code, group] of Object.entries(provinceGroups)) {\n    if (group.address.length === 0 || !group.location) continue;\n\n    const stats = await fs.stat(path.join(NAR_DATA_DIR, group.location));\n\n    datasets.push({\n      provinceCode: code,\n      provinceName: PROVINCE_NAMES[code],\n      addressFiles: group.address.sort(),\n      locationFile: group.location,\n      addressFileCount: group.address.length,\n      estimatedRecords: await this.estimateRecordCount(group.address),\n      lastModified: stats.mtime.toISOString()\n    });\n  }\n\n  return datasets.sort((a, b) => a.provinceCode.localeCompare(b.provinceCode));\n}\n
"},{"location":"v2/features/map/nar-import/#post-apilocationsnarimport","title":"POST /api/locations/nar/import","text":"

Start NAR import job with filters.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Request Body:

{\n  \"provinceCode\": \"35\",\n  \"city\": \"TORONTO\",\n  \"postalCodePrefix\": \"M5\",\n  \"cutId\": 42,\n  \"residentialOnly\": true\n}\n

Parameters:

Parameter Type Required Description provinceCode string Yes Province code (10-62) city string No Filter by MUNICIPALITY (exact match, uppercase) postalCodePrefix string No Filter by postal code prefix (e.g., \"M5\", \"K1A\") cutId number No Filter by cut boundary (point-in-polygon) residentialOnly boolean No Only import BU_USE = 1 (default: false)

Response:

{\n  \"jobId\": \"nar-import-35-20250213-103000\",\n  \"status\": \"processing\",\n  \"provinceCode\": \"35\",\n  \"provinceName\": \"Ontario\",\n  \"filters\": {\n    \"city\": \"TORONTO\",\n    \"postalCodePrefix\": \"M5\",\n    \"cutId\": 42,\n    \"residentialOnly\": true\n  },\n  \"startedAt\": \"2025-02-13T10:30:00Z\",\n  \"estimatedCompletion\": \"2025-02-13T10:45:00Z\"\n}\n

"},{"location":"v2/features/map/nar-import/#get-apilocationsnarimportjobid","title":"GET /api/locations/nar/import/:jobId","text":"

Check import job progress.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Response (In Progress):

{\n  \"jobId\": \"nar-import-35-20250213-103000\",\n  \"status\": \"processing\",\n  \"progress\": {\n    \"total\": 1200000,\n    \"processed\": 600000,\n    \"imported\": 580000,\n    \"skipped\": 15000,\n    \"errors\": 5000,\n    \"percent\": 50.0\n  },\n  \"currentFile\": \"Address_35_part_2.csv\",\n  \"startedAt\": \"2025-02-13T10:30:00Z\",\n  \"estimatedCompletion\": \"2025-02-13T10:45:00Z\"\n}\n

Response (Complete):

{\n  \"jobId\": \"nar-import-35-20250213-103000\",\n  \"status\": \"completed\",\n  \"result\": {\n    \"total\": 1200000,\n    \"processed\": 1200000,\n    \"imported\": 1150000,\n    \"skipped\": 45000,\n    \"errors\": 5000,\n    \"percent\": 100.0\n  },\n  \"statistics\": {\n    \"locationsCreated\": 800000,\n    \"locationsUpdated\": 350000,\n    \"addressesCreated\": 1150000,\n    \"avgConfidence\": 100,\n    \"processingTime\": \"14m 32s\"\n  },\n  \"startedAt\": \"2025-02-13T10:30:00Z\",\n  \"completedAt\": \"2025-02-13T10:44:32Z\"\n}\n

Status Values: - queued: Job created, waiting to start - processing: Import in progress - completed: Import finished successfully - failed: Import failed with errors - cancelled: Import cancelled by user

"},{"location":"v2/features/map/nar-import/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/nar-import/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description NAR_DATA_DIR string /data Directory containing NAR CSV files NAR_BATCH_SIZE number 500 Records per database transaction NAR_IMPORT_TIMEOUT number 3600000 Import timeout in ms (1 hour)"},{"location":"v2/features/map/nar-import/#province-codes","title":"Province Codes","text":"

Complete mapping of NAR province codes:

// nar-import.service.ts\n\nconst PROVINCE_NAMES: Record<string, string> = {\n  '10': 'Newfoundland and Labrador',\n  '11': 'Prince Edward Island',\n  '12': 'Nova Scotia',\n  '13': 'New Brunswick',\n  '24': 'Quebec',\n  '35': 'Ontario',\n  '46': 'Manitoba',\n  '47': 'Saskatchewan',\n  '48': 'Alberta',\n  '59': 'British Columbia',\n  '60': 'Yukon',\n  '61': 'Northwest Territories',\n  '62': 'Nunavut'\n};\n\nconst PROVINCE_ABBREVIATIONS: Record<string, string> = {\n  '10': 'NL',\n  '11': 'PE',\n  '12': 'NS',\n  '13': 'NB',\n  '24': 'QC',\n  '35': 'ON',\n  '46': 'MB',\n  '47': 'SK',\n  '48': 'AB',\n  '59': 'BC',\n  '60': 'YT',\n  '61': 'NT',\n  '62': 'NU'\n};\n
"},{"location":"v2/features/map/nar-import/#coordinate-projection","title":"Coordinate Projection","text":"

EPSG:3347 Definition (Statistics Canada Lambert Conformal Conic):

import proj4 from 'proj4';\n\n// Define EPSG:3347 projection\nproj4.defs('EPSG:3347', '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=63.390675 +lon_0=-91.86666666666666 +x_0=6200000 +y_0=3000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs');\n\n// Convert function\nconst convertCoordinates = (bgX: number, bgY: number): [number, number] => {\n  // Input: [X, Y] in EPSG:3347 (meters)\n  // Output: [longitude, latitude] in WGS84 (degrees)\n  return proj4('EPSG:3347', 'WGS84', [bgX, bgY]);\n};\n

Projection Parameters:

Example Conversion:

// Toronto City Hall coordinates\nconst bgX = 609091.8;  // EPSG:3347 X\nconst bgY = 4834610.7; // EPSG:3347 Y\n\nconst [lng, lat] = proj4('EPSG:3347', 'WGS84', [bgX, bgY]);\n// Result: lng = -79.3832, lat = 43.6532\n
"},{"location":"v2/features/map/nar-import/#import-workflow","title":"Import Workflow","text":""},{"location":"v2/features/map/nar-import/#prepare-nar-files","title":"Prepare NAR Files","text":"

Step 1: Download NAR Data

  1. Visit Elections Canada NAR portal: https://www.elections.ca/NAR
  2. Select \"2025 National Address Register\"
  3. Download province-specific CSV files
  4. Extract ZIP archives

Step 2: Upload Files to Server

# Create data directory if not exists\nmkdir -p /path/to/data\n\n# Upload files via SCP\nscp Address_35_*.csv user@server:/path/to/data/\nscp Location_35.csv user@server:/path/to/data/\n\n# Or mount volume in Docker\n# docker-compose.yml:\nvolumes:\n  - ./data:/data:ro\n

Step 3: Verify File Integrity

# Check file count\nls -l /path/to/data/Address_35_*.csv | wc -l\n\n# Check Location file exists\nls -l /path/to/data/Location_35.csv\n\n# Sample first few rows\nhead -5 /path/to/data/Address_35_part_1.csv\nhead -5 /path/to/data/Location_35.csv\n
"},{"location":"v2/features/map/nar-import/#run-import-via-admin-ui","title":"Run Import via Admin UI","text":"

Step 1: Navigate to NAR Import Tab

  1. Log in as SUPER_ADMIN or MAP_ADMIN
  2. Click Map \u2192 Locations in sidebar
  3. Click NAR Import tab
  4. Available datasets load automatically

Step 2: Select Province

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Available NAR Datasets                  \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 Province         \u2502 Files \u2502 Records      \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 Ontario (35)     \u2502   3   \u2502 1,200,000    \u2502\n\u2502 Quebec (24)      \u2502   6   \u2502   850,000    \u2502\n\u2502 Alberta (48)     \u2502   2   \u2502   450,000    \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\n[Select Province: Ontario \u25bc]\n

Step 3: Configure Filters (Optional)

Filters (Optional):\n\nCity:                [TORONTO          ]\n  Filter by exact municipality name (uppercase)\n\nPostal Code Prefix:  [M5               ]\n  Filter by postal code prefix (2-3 chars)\n\nCut Boundary:        [Downtown Core \u25bc  ]\n  Only import locations within cut polygon\n\n\u2611 Residential Only\n  Only import buildings with BU_USE = 1\n

Step 4: Review Import Summary

Import Summary:\n\nProvince:      Ontario (35)\nFiles:         Address_35_part_1.csv\n               Address_35_part_2.csv\n               Address_35_part_3.csv\n               Location_35.csv\n\nFilters:\n  City:               TORONTO\n  Postal Code:        M5\n  Cut:                Downtown Core\n  Residential Only:   Yes\n\nEstimated Records:  ~50,000 (after filters)\nEstimated Time:     ~3 minutes\n\n[Cancel] [Start Import]\n

Step 5: Monitor Progress

Import in Progress...\n\nCurrent File: Address_35_part_2.csv\nProgress: 600,000 / 1,200,000 (50%)\n\n[\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591] 50%\n\nStatistics:\n  Processed:  600,000\n  Imported:   580,000\n  Skipped:    15,000\n  Errors:     5,000\n\n[Cancel Import]\n

Step 6: Review Results

Import Complete!\n\nFinal Statistics:\n  Total Processed:     1,200,000\n  Successfully Imported: 1,150,000\n  Skipped (Filters):      45,000\n  Errors:                  5,000\n\nDetails:\n  Locations Created:    800,000\n  Locations Updated:    350,000\n  Addresses Created:  1,150,000\n\n  Processing Time:      14m 32s\n  Avg Records/Second:   1,375\n\n[View Import Log] [Import Another Province] [Close]\n
"},{"location":"v2/features/map/nar-import/#import-via-api","title":"Import via API","text":"

Step 1: Get Available Datasets

curl -X GET http://localhost:4000/api/locations/nar/datasets \\\n  -H \"Authorization: Bearer $TOKEN\"\n

Step 2: Start Import

curl -X POST http://localhost:4000/api/locations/nar/import \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"provinceCode\": \"35\",\n    \"city\": \"TORONTO\",\n    \"postalCodePrefix\": \"M5\",\n    \"residentialOnly\": true\n  }'\n

Step 3: Poll Job Status

JOB_ID=\"nar-import-35-20250213-103000\"\n\nwhile true; do\n  STATUS=$(curl -s -X GET \\\n    http://localhost:4000/api/locations/nar/import/$JOB_ID \\\n    -H \"Authorization: Bearer $TOKEN\" \\\n    | jq -r '.status')\n\n  if [ \"$STATUS\" = \"completed\" ] || [ \"$STATUS\" = \"failed\" ]; then\n    break\n  fi\n\n  sleep 5\ndone\n\n# Get final result\ncurl -X GET http://localhost:4000/api/locations/nar/import/$JOB_ID \\\n  -H \"Authorization: Bearer $TOKEN\" | jq\n
"},{"location":"v2/features/map/nar-import/#coordinate-conversion","title":"Coordinate Conversion","text":""},{"location":"v2/features/map/nar-import/#proj4-integration","title":"Proj4 Integration","text":"

Installation:

npm install proj4\n# TypeScript types included in package\n

Service Implementation:

// nar-import.service.ts\n\nimport proj4 from 'proj4';\n\n// Define EPSG:3347 (Statistics Canada Lambert)\nproj4.defs('EPSG:3347',\n  '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=63.390675 ' +\n  '+lon_0=-91.86666666666666 +x_0=6200000 +y_0=3000000 ' +\n  '+ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs'\n);\n\ninterface Coordinates {\n  latitude: number;\n  longitude: number;\n}\n\nclass NARImportService {\n  /**\n   * Convert NAR BG_X/BG_Y (EPSG:3347) to WGS84 lat/lng\n   */\n  convertCoordinates(bgX: number, bgY: number): Coordinates | null {\n    try {\n      // Validate inputs\n      if (!bgX || !bgY || bgX < 0 || bgY < 0) {\n        logger.warn('Invalid BG_X/BG_Y coordinates:', { bgX, bgY });\n        return null;\n      }\n\n      // Convert: EPSG:3347 \u2192 WGS84\n      const [longitude, latitude] = proj4('EPSG:3347', 'WGS84', [bgX, bgY]);\n\n      // Validate output (Canada bounds)\n      if (\n        latitude < 41.0 || latitude > 84.0 ||   // Canada latitude range\n        longitude < -141.0 || longitude > -52.0 // Canada longitude range\n      ) {\n        logger.warn('Converted coordinates outside Canada:', { latitude, longitude });\n        return null;\n      }\n\n      return { latitude, longitude };\n    } catch (error) {\n      logger.error('Coordinate conversion failed:', error);\n      return null;\n    }\n  }\n\n  /**\n   * Get coordinates from NAR record (try BG_X/BG_Y, fallback to lat/lng)\n   */\n  getCoordinates(narLocation: NARLocationRecord): Coordinates | null {\n    // Primary: Convert BG_X/BG_Y\n    if (narLocation.BG_X && narLocation.BG_Y) {\n      const coords = this.convertCoordinates(narLocation.BG_X, narLocation.BG_Y);\n      if (coords) return coords;\n    }\n\n    // Fallback: Use BG_LATITUDE/BG_LONGITUDE directly\n    if (narLocation.BG_LATITUDE && narLocation.BG_LONGITUDE) {\n      return {\n        latitude: narLocation.BG_LATITUDE,\n        longitude: narLocation.BG_LONGITUDE\n      };\n    }\n\n    return null;\n  }\n}\n
"},{"location":"v2/features/map/nar-import/#conversion-examples","title":"Conversion Examples","text":"

Example 1: Toronto City Hall

const bgX = 609091.8;\nconst bgY = 4834610.7;\n\nconst coords = convertCoordinates(bgX, bgY);\n// Result: { latitude: 43.6532, longitude: -79.3832 }\n

Example 2: Parliament Hill, Ottawa

const bgX = 447384.4;\nconst bgY = 5030660.5;\n\nconst coords = convertCoordinates(bgX, bgY);\n// Result: { latitude: 45.4236, longitude: -75.7009 }\n

Example 3: Invalid Coordinates

const bgX = -1000;  // Negative (invalid)\nconst bgY = 0;      // Zero (invalid)\n\nconst coords = convertCoordinates(bgX, bgY);\n// Result: null\n
"},{"location":"v2/features/map/nar-import/#validation","title":"Validation","text":"

Canada Bounds Check:

const isWithinCanada = (lat: number, lng: number): boolean => {\n  return (\n    lat >= 41.0 && lat <= 84.0 &&     // Latitude: Pelee Island to Alert\n    lng >= -141.0 && lng <= -52.0     // Longitude: Yukon to Newfoundland\n  );\n};\n

Precision Check:

// NAR coordinates should have 2-6 decimal places\nconst hasValidPrecision = (value: number): boolean => {\n  const str = value.toString();\n  const decimals = str.split('.')[1]?.length || 0;\n  return decimals >= 2 && decimals <= 6;\n};\n
"},{"location":"v2/features/map/nar-import/#multi-part-file-handling","title":"Multi-Part File Handling","text":""},{"location":"v2/features/map/nar-import/#large-province-processing","title":"Large Province Processing","text":"

Quebec (Province Code 24): - 6 Address files: Address_24_part_1.csv through Address_24_part_6.csv - 1 Location file: Location_24.csv - Total records: ~850,000

Ontario (Province Code 35): - 3 Address files: Address_35_part_1.csv through Address_35_part_3.csv - 1 Location file: Location_35.csv - Total records: ~1,200,000

"},{"location":"v2/features/map/nar-import/#sequential-file-reading","title":"Sequential File Reading","text":"
// nar-import.service.ts\n\nasync processAddressFiles(provinceCode: string): Promise<Map<string, AddressRecord[]>> {\n  const addressMap = new Map<string, AddressRecord[]>();\n\n  // Find all Address files for province\n  const files = await fs.readdir(NAR_DATA_DIR);\n  const addressFiles = files\n    .filter(f => f.match(new RegExp(`^Address_${provinceCode}(?:_part_\\\\d+)?\\\\.csv$`)))\n    .sort(); // Ensure part_1, part_2, ... order\n\n  logger.info(`Processing ${addressFiles.length} address files for province ${provinceCode}`);\n\n  // Process each file sequentially\n  for (const file of addressFiles) {\n    logger.info(`Reading ${file}...`);\n\n    const filePath = path.join(NAR_DATA_DIR, file);\n    const stream = fs.createReadStream(filePath);\n    const parser = stream.pipe(csvParser());\n\n    let rowCount = 0;\n\n    for await (const row of parser) {\n      const locGuid = row.LOC_GUID;\n\n      if (!addressMap.has(locGuid)) {\n        addressMap.set(locGuid, []);\n      }\n\n      addressMap.get(locGuid)!.push({\n        addrGuid: row.ADDR_GUID,\n        civicNo: row.CIVIC_NO,\n        streetName: row.OFFICIAL_STREET_NAME,\n        postalCode: row.POSTAL_CODE,\n        municipality: row.MUNICIPALITY\n      });\n\n      rowCount++;\n\n      if (rowCount % 10000 === 0) {\n        logger.debug(`Processed ${rowCount} addresses from ${file}`);\n      }\n    }\n\n    logger.info(`Completed ${file}: ${rowCount} addresses`);\n  }\n\n  logger.info(`Total unique locations: ${addressMap.size}`);\n  return addressMap;\n}\n
"},{"location":"v2/features/map/nar-import/#memory-management","title":"Memory Management","text":"

Streaming Strategy:

// Process files in chunks to avoid memory overflow\nasync processInChunks(\n  addressMap: Map<string, AddressRecord[]>,\n  locationFile: string,\n  batchSize: number = 500\n): Promise<ImportResult> {\n  const locationPath = path.join(NAR_DATA_DIR, locationFile);\n  const stream = fs.createReadStream(locationPath);\n  const parser = stream.pipe(csvParser());\n\n  let batch: LocationImport[] = [];\n  let stats = { imported: 0, skipped: 0, errors: 0 };\n\n  for await (const row of parser) {\n    const locGuid = row.LOC_GUID;\n    const addresses = addressMap.get(locGuid);\n\n    if (!addresses || addresses.length === 0) {\n      stats.skipped++;\n      continue;\n    }\n\n    // Apply filters\n    if (!this.passesFilters(row, addresses)) {\n      stats.skipped++;\n      continue;\n    }\n\n    // Convert coordinates\n    const coords = this.getCoordinates(row);\n    if (!coords) {\n      stats.errors++;\n      continue;\n    }\n\n    batch.push({ location: row, addresses, coords });\n\n    // Import batch when full\n    if (batch.length >= batchSize) {\n      await this.importBatch(batch);\n      stats.imported += batch.length;\n      batch = [];\n    }\n  }\n\n  // Import remaining\n  if (batch.length > 0) {\n    await this.importBatch(batch);\n    stats.imported += batch.length;\n  }\n\n  return stats;\n}\n

Batch Transaction:

async importBatch(batch: LocationImport[]): Promise<void> {\n  await prisma.$transaction(async (tx) => {\n    for (const item of batch) {\n      // Upsert location\n      const location = await tx.location.upsert({\n        where: { locGuid: item.location.LOC_GUID },\n        update: {\n          address: this.formatAddress(item.addresses[0]),\n          latitude: item.coords.latitude,\n          longitude: item.coords.longitude,\n          postalCode: item.addresses[0].postalCode,\n          federalDistrict: item.location.FED_NUM,\n          buildingUse: parseInt(item.location.BU_USE),\n          municipality: item.location.MUNICIPALITY,\n          geocodedAt: new Date()\n        },\n        create: {\n          locGuid: item.location.LOC_GUID,\n          address: this.formatAddress(item.addresses[0]),\n          latitude: item.coords.latitude,\n          longitude: item.coords.longitude,\n          postalCode: item.addresses[0].postalCode,\n          federalDistrict: item.location.FED_NUM,\n          buildingUse: parseInt(item.location.BU_USE),\n          municipality: item.location.MUNICIPALITY,\n          geocodeConfidence: 100,\n          geocodeProvider: 'NAR',\n          geocodedAt: new Date()\n        }\n      });\n\n      // Insert addresses\n      for (const addr of item.addresses) {\n        await tx.address.upsert({\n          where: { addrGuid: addr.addrGuid },\n          update: { locationId: location.id },\n          create: {\n            addrGuid: addr.addrGuid,\n            locationId: location.id,\n            unitNumber: addr.civicNo\n          }\n        });\n      }\n    }\n  });\n}\n
"},{"location":"v2/features/map/nar-import/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/nar-import/#locationspage-nar-import-tab","title":"LocationsPage - NAR Import Tab","text":"
// LocationsPage.tsx\n\nimport React, { useEffect, useState } from 'react';\nimport { Tabs, Table, Button, Select, Input, Checkbox, Card, Progress, message } from 'antd';\nimport { UploadOutlined } from '@ant-design/icons';\nimport { api } from '@/lib/api';\n\nconst NARImportTab: React.FC = () => {\n  const [datasets, setDatasets] = useState<NARDataset[]>([]);\n  const [selectedProvince, setSelectedProvince] = useState<string | null>(null);\n  const [filters, setFilters] = useState({\n    city: '',\n    postalCodePrefix: '',\n    cutId: null as number | null,\n    residentialOnly: true\n  });\n  const [importing, setImporting] = useState(false);\n  const [progress, setProgress] = useState<ImportProgress | null>(null);\n  const [jobId, setJobId] = useState<string | null>(null);\n\n  useEffect(() => {\n    fetchDatasets();\n  }, []);\n\n  useEffect(() => {\n    if (jobId && importing) {\n      const interval = setInterval(pollProgress, 2000);\n      return () => clearInterval(interval);\n    }\n  }, [jobId, importing]);\n\n  const fetchDatasets = async () => {\n    try {\n      const { data } = await api.get<{ datasets: NARDataset[] }>('/locations/nar/datasets');\n      setDatasets(data.datasets);\n    } catch (error) {\n      message.error('Failed to load NAR datasets');\n    }\n  };\n\n  const pollProgress = async () => {\n    if (!jobId) return;\n\n    try {\n      const { data } = await api.get(`/locations/nar/import/${jobId}`);\n\n      if (data.status === 'completed') {\n        setImporting(false);\n        setProgress(null);\n        message.success(`Import complete! Imported ${data.result.imported} locations.`);\n      } else if (data.status === 'failed') {\n        setImporting(false);\n        setProgress(null);\n        message.error('Import failed. Check logs for details.');\n      } else {\n        setProgress(data.progress);\n      }\n    } catch (error) {\n      message.error('Failed to fetch import progress');\n    }\n  };\n\n  const startImport = async () => {\n    if (!selectedProvince) {\n      message.warning('Please select a province');\n      return;\n    }\n\n    try {\n      const { data } = await api.post('/locations/nar/import', {\n        provinceCode: selectedProvince,\n        ...filters\n      });\n\n      setJobId(data.jobId);\n      setImporting(true);\n      message.info('Import started...');\n    } catch (error) {\n      message.error('Failed to start import');\n    }\n  };\n\n  const datasetColumns = [\n    { title: 'Province', dataIndex: 'provinceName', key: 'name' },\n    { title: 'Files', dataIndex: 'addressFileCount', key: 'files' },\n    { title: 'Estimated Records', dataIndex: 'estimatedRecords', key: 'records',\n      render: (val: number) => val.toLocaleString() },\n    { title: 'Last Modified', dataIndex: 'lastModified', key: 'modified',\n      render: (val: string) => new Date(val).toLocaleDateString() }\n  ];\n\n  return (\n    <div>\n      <Card title=\"Available NAR Datasets\" style={{ marginBottom: 24 }}>\n        <Table\n          dataSource={datasets}\n          columns={datasetColumns}\n          rowKey=\"provinceCode\"\n          pagination={false}\n          onRow={(record) => ({\n            onClick: () => setSelectedProvince(record.provinceCode),\n            style: {\n              cursor: 'pointer',\n              backgroundColor: selectedProvince === record.provinceCode ? '#e6f7ff' : undefined\n            }\n          })}\n        />\n      </Card>\n\n      {selectedProvince && (\n        <Card title=\"Import Configuration\">\n          <div style={{ marginBottom: 16 }}>\n            <label>Province: </label>\n            <strong>{datasets.find(d => d.provinceCode === selectedProvince)?.provinceName}</strong>\n          </div>\n\n          <div style={{ marginBottom: 16 }}>\n            <label>City (Optional): </label>\n            <Input\n              style={{ width: 300 }}\n              placeholder=\"TORONTO\"\n              value={filters.city}\n              onChange={e => setFilters({ ...filters, city: e.target.value.toUpperCase() })}\n            />\n          </div>\n\n          <div style={{ marginBottom: 16 }}>\n            <label>Postal Code Prefix (Optional): </label>\n            <Input\n              style={{ width: 200 }}\n              placeholder=\"M5\"\n              value={filters.postalCodePrefix}\n              onChange={e => setFilters({ ...filters, postalCodePrefix: e.target.value.toUpperCase() })}\n            />\n          </div>\n\n          <div style={{ marginBottom: 16 }}>\n            <Checkbox\n              checked={filters.residentialOnly}\n              onChange={e => setFilters({ ...filters, residentialOnly: e.target.checked })}\n            >\n              Residential Only\n            </Checkbox>\n          </div>\n\n          <Button\n            type=\"primary\"\n            icon={<UploadOutlined />}\n            onClick={startImport}\n            loading={importing}\n            disabled={importing}\n          >\n            Start Import\n          </Button>\n        </Card>\n      )}\n\n      {importing && progress && (\n        <Card title=\"Import Progress\" style={{ marginTop: 24 }}>\n          <Progress percent={progress.percent} status=\"active\" />\n          <div style={{ marginTop: 16 }}>\n            <p>Processed: {progress.processed.toLocaleString()} / {progress.total.toLocaleString()}</p>\n            <p>Imported: {progress.imported.toLocaleString()}</p>\n            <p>Skipped: {progress.skipped.toLocaleString()}</p>\n            <p>Errors: {progress.errors.toLocaleString()}</p>\n          </div>\n        </Card>\n      )}\n    </div>\n  );\n};\n
"},{"location":"v2/features/map/nar-import/#nar-import-service-full-implementation","title":"NAR Import Service - Full Implementation","text":"
// nar-import.service.ts\n\nimport fs from 'fs/promises';\nimport path from 'path';\nimport csvParser from 'csv-parser';\nimport proj4 from 'proj4';\nimport { prisma } from '@/config/database';\nimport { logger } from '@/utils/logger';\n\n// Define EPSG:3347\nproj4.defs('EPSG:3347',\n  '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=63.390675 ' +\n  '+lon_0=-91.86666666666666 +x_0=6200000 +y_0=3000000 ' +\n  '+ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs'\n);\n\nconst NAR_DATA_DIR = process.env.NAR_DATA_DIR || '/data';\nconst BATCH_SIZE = parseInt(process.env.NAR_BATCH_SIZE || '500');\n\ninterface NARAddressRecord {\n  ADDR_GUID: string;\n  LOC_GUID: string;\n  CIVIC_NO: string;\n  OFFICIAL_STREET_NAME: string;\n  POSTAL_CODE: string;\n  MUNICIPALITY: string;\n}\n\ninterface NARLocationRecord {\n  LOC_GUID: string;\n  BG_LATITUDE?: number;\n  BG_LONGITUDE?: number;\n  BG_X?: number;\n  BG_Y?: number;\n  FED_NUM: string;\n  BU_USE: string;\n  MUNICIPALITY: string;\n}\n\nexport class NARImportService {\n  async importProvince(\n    provinceCode: string,\n    filters: {\n      city?: string;\n      postalCodePrefix?: string;\n      cutId?: number;\n      residentialOnly?: boolean;\n    }\n  ): Promise<ImportResult> {\n    logger.info(`Starting NAR import for province ${provinceCode}`, { filters });\n\n    // Load address files into memory map\n    const addressMap = await this.loadAddressFiles(provinceCode, filters);\n\n    // Process location file and import\n    const result = await this.processLocationFile(provinceCode, addressMap, filters);\n\n    logger.info(`NAR import complete for province ${provinceCode}`, result);\n    return result;\n  }\n\n  private async loadAddressFiles(\n    provinceCode: string,\n    filters: { city?: string; postalCodePrefix?: string }\n  ): Promise<Map<string, NARAddressRecord[]>> {\n    const addressMap = new Map<string, NARAddressRecord[]>();\n\n    const files = await fs.readdir(NAR_DATA_DIR);\n    const addressFiles = files\n      .filter(f => f.match(new RegExp(`^Address_${provinceCode}(?:_part_\\\\d+)?\\\\.csv$`)))\n      .sort();\n\n    for (const file of addressFiles) {\n      logger.info(`Reading ${file}...`);\n      const filePath = path.join(NAR_DATA_DIR, file);\n      const stream = require('fs').createReadStream(filePath);\n      const parser = stream.pipe(csvParser());\n\n      for await (const row of parser) {\n        // Apply filters\n        if (filters.city && row.MUNICIPALITY !== filters.city) continue;\n        if (filters.postalCodePrefix && !row.POSTAL_CODE.startsWith(filters.postalCodePrefix)) continue;\n\n        const locGuid = row.LOC_GUID;\n        if (!addressMap.has(locGuid)) {\n          addressMap.set(locGuid, []);\n        }\n        addressMap.get(locGuid)!.push(row);\n      }\n    }\n\n    logger.info(`Loaded ${addressMap.size} unique locations`);\n    return addressMap;\n  }\n\n  private async processLocationFile(\n    provinceCode: string,\n    addressMap: Map<string, NARAddressRecord[]>,\n    filters: { cutId?: number; residentialOnly?: boolean }\n  ): Promise<ImportResult> {\n    const locationFile = `Location_${provinceCode}.csv`;\n    const filePath = path.join(NAR_DATA_DIR, locationFile);\n    const stream = require('fs').createReadStream(filePath);\n    const parser = stream.pipe(csvParser());\n\n    let batch: any[] = [];\n    const stats = { imported: 0, skipped: 0, errors: 0, total: 0 };\n\n    for await (const row of parser) {\n      stats.total++;\n\n      const locGuid = row.LOC_GUID;\n      const addresses = addressMap.get(locGuid);\n\n      if (!addresses || addresses.length === 0) {\n        stats.skipped++;\n        continue;\n      }\n\n      // Residential filter\n      if (filters.residentialOnly && parseInt(row.BU_USE) !== 1) {\n        stats.skipped++;\n        continue;\n      }\n\n      // Convert coordinates\n      const coords = this.getCoordinates(row);\n      if (!coords) {\n        stats.errors++;\n        continue;\n      }\n\n      // Cut filter (if specified)\n      if (filters.cutId) {\n        const cut = await prisma.cut.findUnique({ where: { id: filters.cutId } });\n        if (cut && !this.isPointInPolygon([coords.longitude, coords.latitude], cut.geojson)) {\n          stats.skipped++;\n          continue;\n        }\n      }\n\n      batch.push({ location: row, addresses, coords });\n\n      if (batch.length >= BATCH_SIZE) {\n        await this.importBatch(batch);\n        stats.imported += batch.length;\n        batch = [];\n      }\n    }\n\n    if (batch.length > 0) {\n      await this.importBatch(batch);\n      stats.imported += batch.length;\n    }\n\n    return stats;\n  }\n\n  private getCoordinates(row: NARLocationRecord): { latitude: number; longitude: number } | null {\n    // Try BG_X/BG_Y conversion\n    if (row.BG_X && row.BG_Y) {\n      try {\n        const [lng, lat] = proj4('EPSG:3347', 'WGS84', [row.BG_X, row.BG_Y]);\n        if (lat >= 41 && lat <= 84 && lng >= -141 && lng <= -52) {\n          return { latitude: lat, longitude: lng };\n        }\n      } catch (error) {\n        logger.warn('Coordinate conversion failed:', error);\n      }\n    }\n\n    // Fallback to BG_LATITUDE/BG_LONGITUDE\n    if (row.BG_LATITUDE && row.BG_LONGITUDE) {\n      return { latitude: row.BG_LATITUDE, longitude: row.BG_LONGITUDE };\n    }\n\n    return null;\n  }\n\n  private async importBatch(batch: any[]): Promise<void> {\n    await prisma.$transaction(async (tx) => {\n      for (const item of batch) {\n        const location = await tx.location.upsert({\n          where: { locGuid: item.location.LOC_GUID },\n          update: {\n            address: this.formatAddress(item.addresses[0]),\n            latitude: item.coords.latitude,\n            longitude: item.coords.longitude,\n            postalCode: item.addresses[0].POSTAL_CODE,\n            federalDistrict: item.location.FED_NUM,\n            buildingUse: parseInt(item.location.BU_USE),\n            municipality: item.location.MUNICIPALITY\n          },\n          create: {\n            locGuid: item.location.LOC_GUID,\n            address: this.formatAddress(item.addresses[0]),\n            latitude: item.coords.latitude,\n            longitude: item.coords.longitude,\n            postalCode: item.addresses[0].POSTAL_CODE,\n            federalDistrict: item.location.FED_NUM,\n            buildingUse: parseInt(item.location.BU_USE),\n            municipality: item.location.MUNICIPALITY,\n            geocodeConfidence: 100,\n            geocodeProvider: 'NAR'\n          }\n        });\n\n        for (const addr of item.addresses) {\n          await tx.address.upsert({\n            where: { addrGuid: addr.ADDR_GUID },\n            update: {},\n            create: {\n              addrGuid: addr.ADDR_GUID,\n              locationId: location.id,\n              unitNumber: addr.CIVIC_NO\n            }\n          });\n        }\n      }\n    });\n  }\n\n  private formatAddress(addr: NARAddressRecord): string {\n    return `${addr.CIVIC_NO} ${addr.OFFICIAL_STREET_NAME}`.trim();\n  }\n\n  private isPointInPolygon(point: [number, number], geojson: any): boolean {\n    // Point-in-polygon implementation\n    // (Same as in spatial.ts)\n    return true; // Placeholder\n  }\n}\n
"},{"location":"v2/features/map/nar-import/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/nar-import/#problem-no-datasets-found","title":"Problem: No datasets found","text":"

Symptoms: - GET /api/locations/nar/datasets returns empty array - \"No datasets available\" message in admin

Solutions:

  1. Verify NAR_DATA_DIR path:

    echo $NAR_DATA_DIR\nls -la /data\n

  2. Check Docker volume mount:

    # docker-compose.yml\nservices:\n  api:\n    volumes:\n      - ./data:/data:ro\n

  3. Verify file naming convention:

    # Correct:\nAddress_35_part_1.csv\nLocation_35.csv\n\n# Incorrect:\naddress_35.csv  # Lowercase\nAddresses_35.csv  # Plural\nAddress35.csv  # No underscore\n

  4. Check file permissions:

    chmod 644 /data/Address_*.csv\nchmod 644 /data/Location_*.csv\n

"},{"location":"v2/features/map/nar-import/#problem-coordinate-conversion-errors","title":"Problem: Coordinate conversion errors","text":"

Symptoms: - Many locations skipped during import - \"Converted coordinates outside Canada\" warnings - Null latitude/longitude in database

Solutions:

  1. Verify BG_X/BG_Y values:

    // Valid range for Canada (EPSG:3347):\n// BG_X: ~400,000 to 3,000,000\n// BG_Y: ~4,600,000 to 9,000,000\n\nconsole.log('BG_X:', narRecord.BG_X);  // Should be 6-7 digits\nconsole.log('BG_Y:', narRecord.BG_Y);  // Should be 7 digits\n

  2. Test with known coordinates:

    // Toronto City Hall\nconst [lng, lat] = proj4('EPSG:3347', 'WGS84', [609091.8, 4834610.7]);\nconsole.log('Expected: 43.6532, -79.3832');\nconsole.log('Got:', lat, lng);\n

  3. Fallback to BG_LATITUDE/BG_LONGITUDE:

    // If BG_X/BG_Y missing or invalid, use lat/lng directly\nif (!coords && narRecord.BG_LATITUDE && narRecord.BG_LONGITUDE) {\n  coords = {\n    latitude: narRecord.BG_LATITUDE,\n    longitude: narRecord.BG_LONGITUDE\n  };\n}\n

  4. Check proj4 definition:

    npm list proj4\n# Ensure version 2.8.0+\n

"},{"location":"v2/features/map/nar-import/#problem-import-very-slow-30min-for-100k-records","title":"Problem: Import very slow (> 30min for 100k records)","text":"

Symptoms: - Import hangs on large provinces - Memory usage grows over time - Database connection timeouts

Solutions:

  1. Increase batch size:

    NAR_BATCH_SIZE=1000  # Default: 500\n

  2. Use streaming instead of loading all addresses:

    // DON'T do this (loads all into memory):\nconst allAddresses = await readAllAddressFiles();\n\n// DO this (stream and process incrementally):\nfor await (const addressBatch of streamAddressFiles()) {\n  processBatch(addressBatch);\n}\n

  3. Optimize database indexes:

    CREATE INDEX CONCURRENTLY idx_locations_loc_guid ON \"Location\"(locGuid);\nCREATE INDEX CONCURRENTLY idx_addresses_addr_guid ON \"Address\"(addrGuid);\n

  4. Disable geocoding during import:

    // Skip geocoding service since NAR already has coordinates\ngeocodeConfidence: 100,\ngeocodeProvider: 'NAR'\n// No call to geocodingService.geocode()\n

  5. Use worker threads for parallel processing:

    import { Worker } from 'worker_threads';\n\nconst workers = [];\nfor (let i = 0; i < 4; i++) {\n  const worker = new Worker('./nar-import-worker.js');\n  workers.push(worker);\n}\n

"},{"location":"v2/features/map/nar-import/#problem-duplicate-loc_guid-errors","title":"Problem: Duplicate LOC_GUID errors","text":"

Symptoms: - Unique constraint violation on locGuid - Import fails mid-process - \"Duplicate key value violates unique constraint\" error

Solutions:

  1. Use UPSERT instead of INSERT:

    await prisma.location.upsert({\n  where: { locGuid: narRecord.LOC_GUID },\n  update: { /* update fields */ },\n  create: { /* create fields */ }\n});\n

  2. Check for corrupt NAR files:

    # Count unique LOC_GUIDs\ncut -d, -f2 Address_35_part_1.csv | sort | uniq | wc -l\n\n# Check for duplicates\ncut -d, -f2 Address_35_part_1.csv | sort | uniq -d\n

  3. Clean up partial imports:

    -- Delete locations from failed import\nDELETE FROM \"Location\" WHERE \"geocodeProvider\" = 'NAR' AND \"createdAt\" > '2025-02-13';\n

  4. Implement transaction rollback on error:

    try {\n  await prisma.$transaction(async (tx) => {\n    // Import batch\n  });\n} catch (error) {\n  logger.error('Batch failed, rolling back:', error);\n  // Transaction automatically rolled back\n}\n

"},{"location":"v2/features/map/nar-import/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/nar-import/#import-speed","title":"Import Speed","text":"

Benchmarks:

Province Records Files Time Records/Second PEI (11) 15,000 1 12s 1,250 Nova Scotia (12) 85,000 1 1m 10s 1,214 Quebec (24) 850,000 6 11m 20s 1,250 Ontario (35) 1,200,000 3 14m 30s 1,379

Factors: - Batch size: 500 (optimal for most systems) - Coordinate conversion: ~0.1ms per record - Database write: ~0.5ms per location (depends on disk speed) - Total overhead: ~0.7ms per record

"},{"location":"v2/features/map/nar-import/#memory-usage","title":"Memory Usage","text":"

Peak Memory: - Address map (in-memory): ~200MB per 100k records - CSV parser buffer: ~10MB - Batch buffer: ~5MB (500 records) - Total: ~220MB per 100k records

Optimization: - Stream address files instead of loading all - Process location file in chunks - Clear batch after each commit - Limit concurrent transactions

"},{"location":"v2/features/map/nar-import/#database-load","title":"Database Load","text":"

Transaction Rate: - 1 transaction per batch (500 records) - ~2-3 transactions/second - Low database CPU (~10-20%) - Moderate disk I/O (sequential writes)

Connection Pool:

// prisma/schema.prisma\ndatasource db {\n  url = env(\"DATABASE_URL\")\n  connection_limit = 10\n}\n

"},{"location":"v2/features/map/nar-import/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/map/nar-import/#backend-documentation","title":"Backend Documentation","text":""},{"location":"v2/features/map/nar-import/#frontend-documentation","title":"Frontend Documentation","text":""},{"location":"v2/features/map/nar-import/#database-documentation","title":"Database Documentation","text":""},{"location":"v2/features/map/nar-import/#external-resources","title":"External Resources","text":""},{"location":"v2/features/map/shifts/","title":"Volunteer Shift Management","text":""},{"location":"v2/features/map/shifts/#overview","title":"Overview","text":"

The shifts system enables campaigns to organize volunteer activities with time-based scheduling, capacity management, and cut assignment. It supports public shift signup with automatic TEMP user creation for unauthenticated volunteers.

Key Capabilities:

Use Cases:

"},{"location":"v2/features/map/shifts/#architecture","title":"Architecture","text":"
graph TD\n    A[Admin] -->|Creates Shift| B[ShiftsPage]\n    B -->|POST /api/map/shifts| C[Shifts Service]\n    C -->|Validate| D[Shift Model]\n    D -->|Linked To| E[Cut Model]\n\n    F[Public User] -->|Browse Shifts| G[Public ShiftsPage]\n    G -->|GET /api/public/map/shifts| C\n    C -->|Filter upcoming=true| D\n\n    F -->|Signup| H[Signup Modal]\n    H -->|POST /api/public/map/shifts/:id/signup| C\n    C -->|Check Capacity| D\n    C -->|Create TEMP User| I[User Service]\n    C -->|Create Signup| J[ShiftSignup Model]\n    C -->|Send Email| K[Email Service]\n\n    L[Volunteer] -->|View Assignments| M[VolunteerShiftsPage]\n    M -->|GET /api/map/canvass/volunteer/assignments| N[Canvass Service]\n    N -->|Filter by userId| J\n    N -->|Include Cut| E\n\n    D -->|1:N| J\n    D -->|N:1| E\n\n    style D fill:#e1f5ff\n    style J fill:#e1f5ff\n    style E fill:#e1f5ff\n    style I fill:#e8f5e9

Flow Description:

  1. Admin creates shift \u2192 Validates date/time, assigns cut (optional), saves to database
  2. Public user browses \u2192 Query upcoming shifts (isPublic=true, date >=today), display cards
  3. Public signup \u2192 Check capacity, create TEMP user if unauthenticated, create signup record, send confirmation email
  4. Volunteer views assignments \u2192 Query signups for current user, include shift + cut details
  5. Shift capacity check \u2192 Auto-update status to FULL when currentVolunteers >= maxVolunteers
"},{"location":"v2/features/map/shifts/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/shifts/#shift-model","title":"Shift Model","text":"

See Shift Model Documentation for full schema.

Key Fields:

Status Enum:

enum ShiftStatus {\n  OPEN       // Accepting signups\n  FULL       // At capacity\n  CANCELLED  // Cancelled by admin\n}\n
"},{"location":"v2/features/map/shifts/#shiftsignup-model","title":"ShiftSignup Model","text":"

See ShiftSignup Model Documentation for full schema.

Key Fields:

Signup Source Enum:

enum SignupSource {\n  AUTHENTICATED  // Logged-in user signup\n  PUBLIC         // Public signup (creates TEMP user)\n  ADMIN          // Admin created signup\n}\n

Signup Status Enum:

enum SignupStatus {\n  CONFIRMED  // Signup active\n  CANCELLED  // Volunteer cancelled\n  NO_SHOW    // Marked as no-show by admin\n}\n

Related Models:

"},{"location":"v2/features/map/shifts/#api-endpoints","title":"API Endpoints","text":"

See Shifts Backend Module Documentation for full API reference.

Admin Endpoints:

Method Endpoint Auth Description GET /api/map/shifts MAP_ADMIN List shifts with pagination, search, filters GET /api/map/shifts/stats MAP_ADMIN Get shift statistics (total, upcoming, by status) GET /api/map/shifts/:id MAP_ADMIN Get shift details with signups POST /api/map/shifts MAP_ADMIN Create new shift PATCH /api/map/shifts/:id MAP_ADMIN Update shift DELETE /api/map/shifts/:id MAP_ADMIN Delete shift (cascade signups) POST /api/map/shifts/:id/signups MAP_ADMIN Manually add signup PATCH /api/map/shifts/:id/signups/:signupId MAP_ADMIN Update signup (change status, notes) DELETE /api/map/shifts/:id/signups/:signupId MAP_ADMIN Delete signup POST /api/map/shifts/:id/email-volunteers MAP_ADMIN Send email to all shift volunteers

Public Endpoints:

Method Endpoint Auth Description GET /api/public/map/shifts None List upcoming public shifts (isPublic=true, date >=today) GET /api/public/map/shifts/:id None Get public shift details POST /api/public/map/shifts/:id/signup None Public signup (creates TEMP user if unauthenticated)

Volunteer Endpoints:

Method Endpoint Auth Description GET /api/map/canvass/volunteer/assignments Any logged-in user Get shifts user signed up for DELETE /api/map/shifts/:id/signups/cancel Any logged-in user Cancel own signup"},{"location":"v2/features/map/shifts/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/shifts/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description EMAIL_TEST_MODE boolean false Send confirmation emails to MailHog (dev) SMTP_HOST string - SMTP server for confirmation emails SMTP_PORT number 587 SMTP port SMTP_USER string - SMTP username SMTP_PASSWORD string - SMTP password"},{"location":"v2/features/map/shifts/#email-templates","title":"Email Templates","text":"

Shift Confirmation Email:

Subject: Shift Confirmation - {{shift.title}}

Body:

Hi {{userName}},\n\nYou're confirmed for:\n{{shift.title}}\n\nDate: {{shift.date}}\nTime: {{shift.startTime}} - {{shift.endTime}}\nLocation: {{shift.location}}\n\n{{#if shift.cut}}\nTerritory: {{shift.cut.name}}\n{{/if}}\n\n{{#if shift.description}}\nDetails:\n{{shift.description}}\n{{/if}}\n\nTo cancel your signup, reply to this email.\n\nThank you!\n

Admin Email All Volunteers:

Subject: Configurable by admin

Body: Configurable by admin (supports {{name}}, {{email}}, {{phone}} placeholders)

"},{"location":"v2/features/map/shifts/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/map/shifts/#creating-a-shift","title":"Creating a Shift","text":"

Step 1: Navigate to Shifts Page

Navigate to Map \u2192 Shifts in the admin sidebar.

![ShiftsPage Screenshot Placeholder]

Step 2: Click \"Add Shift\"

Click + Add Shift button in the top-right corner.

Step 3: Fill Shift Form

Complete shift details:

Step 4: Save Shift

Click Create to save shift. Status is automatically set to OPEN.

"},{"location":"v2/features/map/shifts/#managing-signups","title":"Managing Signups","text":"

Step 1: View Shift

Click Signups button on a shift row to open signups drawer.

Step 2: View Signup List

Drawer displays:

Step 3: Manually Add Signup (Admin)

Click Add Signup button in drawer:

System will:

  1. Check capacity (reject if FULL)
  2. Create TEMP user if email not in database
  3. Create signup with source=ADMIN
  4. Send confirmation email
  5. Update shift.currentVolunteers count

Step 4: Mark No-Show

Click Mark No-Show on signup row to update status. Useful for tracking volunteer reliability.

Step 5: Delete Signup

Click Delete to remove signup. Decrements shift.currentVolunteers count.

"},{"location":"v2/features/map/shifts/#emailing-all-volunteers","title":"Emailing All Volunteers","text":"

Step 1: Click \"Email All\"

On shift row, click Email All button.

Step 2: Compose Email

Modal opens with:

Step 3: Preview

Click Preview to see sample email with placeholders replaced.

Step 4: Send

Click Send Email to queue emails to all CONFIRMED volunteers. Uses BullMQ email queue for async processing.

"},{"location":"v2/features/map/shifts/#updating-shift-status","title":"Updating Shift Status","text":"

Step 1: Edit Shift

Click Edit on shift row.

Step 2: Change Status

Update status dropdown:

Step 3: Save

Click Update. If status changed to CANCELLED, optionally send cancellation email to all volunteers.

"},{"location":"v2/features/map/shifts/#public-workflow","title":"Public Workflow","text":"

Public users can browse and signup for shifts without authentication.

Step 1: Navigate to Public Shifts Page

Visit /shifts (public route, no auth required).

Step 2: Browse Shifts

View upcoming shifts as cards:

Step 3: Filter Shifts

Use filters:

Step 4: Click Signup

Click Signup button on shift card. Modal opens.

Step 5: Fill Signup Form

Complete form:

Step 6: Submit

Click Sign Up. System will:

  1. Check capacity (reject if FULL)
  2. Create TEMP user with email (if not exists)
  3. Create shift signup with source=PUBLIC
  4. Send confirmation email
  5. Update shift.currentVolunteers count
  6. Auto-update status to FULL if at capacity

Step 7: Receive Confirmation

Check email for confirmation with shift details.

"},{"location":"v2/features/map/shifts/#volunteer-workflow","title":"Volunteer Workflow","text":"

Authenticated volunteers can view assigned shifts and cancel signups.

Step 1: Login

Login at /login with volunteer account.

Step 2: Navigate to Assignments

Navigate to Volunteer \u2192 My Assignments.

Step 3: View Assigned Shifts

Table displays:

Step 4: View Shift Details

Click shift title to view:

Step 5: Cancel Signup

Click Cancel Signup button. Confirmation modal appears.

Step 6: Confirm Cancellation

Click Confirm. System will:

  1. Update signup status to CANCELLED
  2. Decrement shift.currentVolunteers count
  3. Update shift status to OPEN if was FULL
  4. Send cancellation confirmation email
"},{"location":"v2/features/map/shifts/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/shifts/#shift-service-create-backend","title":"Shift Service Create (Backend)","text":"
// api/src/modules/map/shifts/shifts.service.ts\nasync create(data: CreateShiftInput, userId: string) {\n  const shift = await prisma.shift.create({\n    data: {\n      title: data.title,\n      description: data.description,\n      date: new Date(data.date),\n      startTime: data.startTime,\n      endTime: data.endTime,\n      location: data.location,\n      maxVolunteers: data.maxVolunteers,\n      isPublic: data.isPublic,\n      cutId: data.cutId,\n      createdBy: userId,\n    },\n  });\n\n  return shift;\n}\n
"},{"location":"v2/features/map/shifts/#public-signup-backend","title":"Public Signup (Backend)","text":"
// api/src/modules/map/shifts/shifts.service.ts\nimport bcrypt from 'bcryptjs';\n\nasync publicSignup(shiftId: string, data: PublicSignupInput) {\n  const shift = await prisma.shift.findUnique({ where: { id: shiftId } });\n\n  if (!shift) {\n    throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');\n  }\n\n  // Check capacity\n  if (shift.currentVolunteers >= shift.maxVolunteers) {\n    throw new AppError(400, 'Shift is full', 'SHIFT_FULL');\n  }\n\n  // Find or create TEMP user\n  let user = await prisma.user.findUnique({ where: { email: data.email } });\n\n  if (!user) {\n    const password = generateReadablePassword(); // e.g., \"BlueEagle42\"\n    const hashedPassword = await bcrypt.hash(password, 10);\n\n    user = await prisma.user.create({\n      data: {\n        email: data.email,\n        name: data.name,\n        phone: data.phone,\n        password: hashedPassword,\n        role: 'TEMP',\n      },\n    });\n\n    logger.info('Created TEMP user for shift signup', {\n      email: data.email,\n      shiftId,\n    });\n  }\n\n  // Create signup\n  const signup = await prisma.shiftSignup.create({\n    data: {\n      shiftId,\n      userId: user.id,\n      userEmail: user.email,\n      userName: user.name ?? data.name,\n      userPhone: user.phone ?? data.phone,\n      signupSource: SignupSource.PUBLIC,\n      status: SignupStatus.CONFIRMED,\n    },\n  });\n\n  // Increment volunteer count\n  await prisma.shift.update({\n    where: { id: shiftId },\n    data: {\n      currentVolunteers: { increment: 1 },\n      status: shift.currentVolunteers + 1 >= shift.maxVolunteers\n        ? ShiftStatus.FULL\n        : shift.status,\n    },\n  });\n\n  // Send confirmation email\n  await emailService.sendShiftConfirmation(user.email, shift, user.name ?? data.name);\n\n  recordShiftSignup('public');\n\n  return signup;\n}\n
"},{"location":"v2/features/map/shifts/#generate-readable-password-backend","title":"Generate Readable Password (Backend)","text":"
// api/src/modules/map/shifts/shifts.service.ts\nconst adjectives = ['Blue', 'Red', 'Green', 'Swift', 'Bright', 'Bold', 'Calm', 'Fair'];\nconst nouns = ['Eagle', 'River', 'Mountain', 'Star', 'Forest', 'Lake', 'Wolf', 'Hawk'];\n\nfunction generateReadablePassword(): string {\n  const adj = adjectives[Math.floor(Math.random() * adjectives.length)];\n  const noun = nouns[Math.floor(Math.random() * nouns.length)];\n  const num = Math.floor(Math.random() * 90) + 10;\n  return `${adj}${noun}${num}`;\n}\n\n// Example output: \"BoldWolf72\", \"SwiftStar45\"\n
"},{"location":"v2/features/map/shifts/#shift-confirmation-email-backend","title":"Shift Confirmation Email (Backend)","text":"
// api/src/services/email.service.ts\nasync sendShiftConfirmation(\n  to: string,\n  shift: Shift,\n  userName: string\n): Promise<void> {\n  const subject = `Shift Confirmation - ${shift.title}`;\n\n  const body = `\nHi ${userName},\n\nYou're confirmed for:\n${shift.title}\n\nDate: ${dayjs(shift.date).format('MMMM D, YYYY')}\nTime: ${shift.startTime} - ${shift.endTime}\nLocation: ${shift.location}\n\n${shift.description ? `\\nDetails:\\n${shift.description}\\n` : ''}\n\nTo cancel your signup, reply to this email.\n\nThank you!\n`;\n\n  await this.sendEmail({ to, subject, text: body });\n}\n
"},{"location":"v2/features/map/shifts/#public-shifts-list-frontend","title":"Public Shifts List (Frontend)","text":"
// admin/src/pages/public/ShiftsPage.tsx\nconst fetchShifts = async () => {\n  try {\n    const { data } = await axios.get('/api/public/map/shifts', {\n      params: {\n        upcoming: true, // Only show future shifts\n      },\n    });\n\n    setShifts(data.shifts);\n  } catch (error) {\n    message.error('Failed to load shifts');\n  }\n};\n\nuseEffect(() => {\n  fetchShifts();\n}, []);\n
"},{"location":"v2/features/map/shifts/#signup-modal-frontend","title":"Signup Modal (Frontend)","text":"
// admin/src/pages/public/ShiftsPage.tsx\nconst handleSignup = async (values: any) => {\n  try {\n    await axios.post(`/api/public/map/shifts/${selectedShift.id}/signup`, {\n      name: values.name,\n      email: values.email,\n      phone: values.phone,\n    });\n\n    message.success('Signup successful! Check your email for confirmation.');\n    setSignupModalOpen(false);\n    signupForm.resetFields();\n    fetchShifts(); // Refresh to update volunteer count\n  } catch (error: any) {\n    if (error.response?.data?.code === 'SHIFT_FULL') {\n      message.error('This shift is now full. Please choose another shift.');\n    } else {\n      message.error('Signup failed. Please try again.');\n    }\n  }\n};\n
"},{"location":"v2/features/map/shifts/#volunteer-assignments-frontend","title":"Volunteer Assignments (Frontend)","text":"
// admin/src/pages/volunteer/VolunteerShiftsPage.tsx\nconst fetchAssignments = async () => {\n  try {\n    const { data } = await api.get('/map/canvass/volunteer/assignments');\n\n    setAssignments(data);\n  } catch (error) {\n    message.error('Failed to load assignments');\n  }\n};\n
"},{"location":"v2/features/map/shifts/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/shifts/#issue-shift-status-not-auto-updating-to-full","title":"Issue: Shift Status Not Auto-Updating to FULL","text":"

Symptoms:

Causes:

Solutions:

  1. Use database transaction for capacity check + signup creation:
await prisma.$transaction(async (tx) => {\n  const shift = await tx.shift.findUnique({\n    where: { id: shiftId },\n    select: { currentVolunteers: true, maxVolunteers: true },\n  });\n\n  if (shift.currentVolunteers >= shift.maxVolunteers) {\n    throw new AppError(400, 'Shift is full', 'SHIFT_FULL');\n  }\n\n  await tx.shiftSignup.create({ data: signupData });\n\n  await tx.shift.update({\n    where: { id: shiftId },\n    data: {\n      currentVolunteers: { increment: 1 },\n      status: shift.currentVolunteers + 1 >= shift.maxVolunteers\n        ? ShiftStatus.FULL\n        : shift.status,\n    },\n  });\n});\n
  1. Verify count matches reality:
-- Check if currentVolunteers matches actual signup count\nSELECT s.id, s.title, s.currentVolunteers,\n  COUNT(ss.id) as actual_signups\nFROM \"Shift\" s\nLEFT JOIN \"ShiftSignup\" ss ON s.id = ss.\"shiftId\"\n  AND ss.status = 'CONFIRMED'\nGROUP BY s.id\nHAVING s.\"currentVolunteers\" != COUNT(ss.id);\n
  1. Recalculate counts:
// Admin utility to fix counts\nasync function recalculateShiftCounts() {\n  const shifts = await prisma.shift.findMany();\n\n  for (const shift of shifts) {\n    const count = await prisma.shiftSignup.count({\n      where: {\n        shiftId: shift.id,\n        status: SignupStatus.CONFIRMED,\n      },\n    });\n\n    await prisma.shift.update({\n      where: { id: shift.id },\n      data: {\n        currentVolunteers: count,\n        status: count >= shift.maxVolunteers ? ShiftStatus.FULL : ShiftStatus.OPEN,\n      },\n    });\n  }\n}\n
"},{"location":"v2/features/map/shifts/#issue-confirmation-emails-not-sending","title":"Issue: Confirmation Emails Not Sending","text":"

Symptoms:

Causes:

Solutions:

  1. Check email service config:
# Verify SMTP settings in .env\ngrep \"SMTP_\\|EMAIL_TEST_MODE\" .env\n\n# In development, use MailHog\nEMAIL_TEST_MODE=true\n\n# In production, configure SMTP\nEMAIL_TEST_MODE=false\nSMTP_HOST=smtp.gmail.com\nSMTP_PORT=587\nSMTP_USER=your-email@gmail.com\nSMTP_PASSWORD=your-app-password\n
  1. Test email service:
# Send test email via API\ncurl -X POST http://localhost:4000/api/test-email \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"to\":\"test@example.com\",\"subject\":\"Test\",\"text\":\"Test email\"}'\n
  1. Check MailHog in dev:
# Access MailHog UI\nopen http://localhost:8025\n\n# View email queue in BullMQ\ndocker compose exec redis redis-cli KEYS \"bull:email-queue:*\"\n
  1. Check spam folder (production):

Add SPF/DKIM/DMARC records to domain to improve deliverability.

"},{"location":"v2/features/map/shifts/#issue-temp-user-password-security","title":"Issue: TEMP User Password Security","text":"

Symptoms:

Causes:

Solutions:

  1. Ensure generated password meets policy:
function generateReadablePassword(): string {\n  // Must meet: 12+ chars, uppercase, lowercase, digit\n  const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; // Uppercase\n  const noun = nouns[Math.floor(Math.random() * nouns.length)]; // Uppercase\n  const num = Math.floor(Math.random() * 90) + 10; // 2 digits\n  const lower = 'abc'; // Lowercase\n\n  return `${adj}${noun}${num}${lower}`; // E.g., \"BoldWolf72abc\" (14 chars)\n}\n
  1. Send password to user (security risk, consider alternative):

Include password in confirmation email (only for TEMP users, one-time):

Your temporary account has been created.\n\nEmail: {{email}}\nPassword: {{password}}\n\nPlease change your password after logging in.\n

Better Alternative: Use passwordless login link:

Click here to confirm your shift and access your account:\nhttps://app.cmlite.org/confirm-shift/{{signupToken}}\n
"},{"location":"v2/features/map/shifts/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/shifts/#shift-query-optimization","title":"Shift Query Optimization","text":"

Index Upcoming Shifts:

Create composite index for common query:

CREATE INDEX idx_shifts_upcoming ON \"Shift\" (date, \"isPublic\", status)\nWHERE date >= CURRENT_DATE;\n

Efficient Public Query:

// Only query future public shifts\nconst shifts = await prisma.shift.findMany({\n  where: {\n    isPublic: true,\n    date: { gte: new Date() },\n    status: { not: ShiftStatus.CANCELLED },\n  },\n  orderBy: { date: 'asc' },\n  include: {\n    cut: { select: { id: true, name: true } },\n    _count: {\n      select: { signups: { where: { status: SignupStatus.CONFIRMED } } },\n    },\n  },\n});\n
"},{"location":"v2/features/map/shifts/#email-queue-performance","title":"Email Queue Performance","text":"

Batch Email Sending:

Use BullMQ queue to avoid blocking API requests:

// Add email jobs to queue\nfor (const volunteer of volunteers) {\n  await emailQueue.add('send-email', {\n    to: volunteer.email,\n    subject: 'Shift Update',\n    text: message,\n  });\n}\n\n// Worker processes jobs asynchronously\nemailQueue.process('send-email', async (job) => {\n  await emailService.sendEmail(job.data);\n});\n
"},{"location":"v2/features/map/shifts/#concurrent-signup-handling","title":"Concurrent Signup Handling","text":"

Prevent Race Conditions:

Use database transactions with SELECT FOR UPDATE:

await prisma.$transaction(async (tx) => {\n  const shift = await tx.shift.findUnique({\n    where: { id: shiftId },\n    // Lock row to prevent concurrent updates\n  });\n\n  if (shift.currentVolunteers >= shift.maxVolunteers) {\n    throw new AppError(400, 'Shift is full', 'SHIFT_FULL');\n  }\n\n  // Create signup and update count atomically\n  await tx.shiftSignup.create({ data: signupData });\n  await tx.shift.update({\n    where: { id: shiftId },\n    data: { currentVolunteers: { increment: 1 } },\n  });\n});\n
"},{"location":"v2/features/map/shifts/#related-documentation","title":"Related Documentation","text":"

Backend Modules:

Frontend Pages:

Database:

Features:

"},{"location":"v2/features/map/tracking/","title":"GPS Tracking System","text":""},{"location":"v2/features/map/tracking/#overview","title":"Overview","text":"

The GPS tracking system provides real-time volunteer location monitoring with breadcrumb trail recording, distance calculation, and route visualization. It integrates with canvassing sessions for field organizing oversight and volunteer safety.

Key Capabilities:

"},{"location":"v2/features/map/tracking/#architecture","title":"Architecture","text":"
graph TD\n    A[Volunteer GPS] -->|watchPosition| B[GPSTracker Component]\n    B -->|Buffer Points| C[Local Storage]\n    C -->|Submit Every 10s| D[POST /api/map/tracking/sessions/:id/points]\n    D -->|Batch Insert| E[Tracking Service]\n    E -->|Save Points| F[(TrackPoint Model)]\n    E -->|Calculate Distance| G[Haversine Formula]\n    G -->|Update Session| H[(TrackingSession Model)]\n\n    I[Canvass Session] -->|Start| J[Canvass Service]\n    J -->|Create 1:1| E\n    E -->|Create| H\n\n    K[Admin] -->|View Live Map| L[CanvassDashboardPage]\n    L -->|GET /api/map/tracking/admin/live| E\n    E -->|Query Active| H\n    E -->|Return Positions| L\n\n    M[Volunteer] -->|View Route History| N[MyRoutesPage]\n    N -->|GET /api/map/tracking/sessions/:id/route| E\n    E -->|Query Points| F\n    E -->|Generate Polyline| N\n\n    H -->|1:1| I\n    H -->|1:N| F\n\n    style H fill:#e1f5ff\n    style F fill:#e1f5ff

Flow Description:

  1. Canvass session starts \u2192 Create TrackingSession linked 1:1
  2. GPS auto-tracking \u2192 watchPosition submits points every 10s
  3. Distance calculation \u2192 Haversine formula calculates incremental distance
  4. Event markers \u2192 Mark visits, session start/end with eventType
  5. Admin oversight \u2192 View live volunteer positions on dashboard
  6. Route history \u2192 Generate polyline from saved TrackPoints
"},{"location":"v2/features/map/tracking/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/tracking/#trackingsession-model","title":"TrackingSession Model","text":"

See TrackingSession Model Documentation.

Key Fields:

"},{"location":"v2/features/map/tracking/#trackpoint-model","title":"TrackPoint Model","text":"

See TrackPoint Model Documentation.

Key Fields:

Event Type Enum:

enum TrackPointEventType {\n  LOCATION_ADDED   // Regular GPS breadcrumb\n  VISIT_RECORDED   // Canvass visit recorded\n  SESSION_STARTED  // Canvass session started\n  SESSION_ENDED    // Canvass session ended\n}\n
"},{"location":"v2/features/map/tracking/#api-endpoints","title":"API Endpoints","text":"

See Tracking Backend Module Documentation.

Volunteer Endpoints:

Method Endpoint Auth Description POST /api/map/tracking/sessions Any logged-in user Start tracking session PATCH /api/map/tracking/sessions/:id/end Any logged-in user End tracking session POST /api/map/tracking/sessions/:id/points Any logged-in user Submit batch of GPS points GET /api/map/tracking/sessions/:id Any logged-in user Get tracking session details GET /api/map/tracking/sessions/:id/route Any logged-in user Get route polyline (all points)

Admin Endpoints:

Method Endpoint Auth Description GET /api/map/tracking/admin/live MAP_ADMIN Get live volunteer positions GET /api/map/tracking/admin/sessions/:id MAP_ADMIN Get volunteer tracking session GET /api/map/tracking/admin/sessions/:id/route MAP_ADMIN Get volunteer route"},{"location":"v2/features/map/tracking/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/tracking/#gps-tracking-settings","title":"GPS Tracking Settings","text":"Setting Default Description SUBMIT_INTERVAL_MS 10000 Submit GPS points every 10 seconds MAX_DISTANCE_JUMP_M 1000 Ignore GPS glitches >1km distance HIGH_ACCURACY true Use GPS + WiFi + cellular (vs WiFi only) MAX_AGE_MS 0 Don't use cached GPS position TIMEOUT_MS 10000 GPS position timeout (10s)"},{"location":"v2/features/map/tracking/#privacy-security","title":"Privacy & Security","text":""},{"location":"v2/features/map/tracking/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/tracking/#start-tracking-session-backend","title":"Start Tracking Session (Backend)","text":"
// api/src/modules/map/tracking/tracking.service.ts\nasync startSession(userId: string, data: StartTrackingInput) {\n  const { canvassSessionId, latitude, longitude } = data;\n\n  // Check for existing active session\n  const existing = await prisma.trackingSession.findFirst({\n    where: { userId, isActive: true },\n  });\n\n  if (existing) return existing; // Reuse existing session\n\n  return prisma.trackingSession.create({\n    data: {\n      userId,\n      canvassSessionId: canvassSessionId ?? null,\n      lastLatitude: latitude != null ? new Prisma.Decimal(latitude) : null,\n      lastLongitude: longitude != null ? new Prisma.Decimal(longitude) : null,\n      lastRecordedAt: latitude != null ? new Date() : null,\n    },\n  });\n}\n
"},{"location":"v2/features/map/tracking/#submit-gps-points-backend","title":"Submit GPS Points (Backend)","text":"
// api/src/modules/map/tracking/tracking.service.ts\nconst MAX_DISTANCE_JUMP_M = 1000;\n\nasync submitPoints(sessionId: string, userId: string, data: SubmitPointsInput) {\n  const session = await prisma.trackingSession.findFirst({\n    where: { id: sessionId, userId, isActive: true },\n  });\n\n  if (!session) {\n    throw new AppError(404, 'Active tracking session not found', 'SESSION_NOT_FOUND');\n  }\n\n  const { points } = data;\n\n  // Batch insert all points\n  await prisma.trackPoint.createMany({\n    data: points.map((p) => ({\n      trackingSessionId: sessionId,\n      latitude: new Prisma.Decimal(p.latitude),\n      longitude: new Prisma.Decimal(p.longitude),\n      accuracy: p.accuracy ?? null,\n      recordedAt: new Date(p.recordedAt),\n      eventType: p.eventType ?? null,\n    })),\n  });\n\n  // Calculate incremental distance\n  let addedDistance = 0;\n  let prevLat = session.lastLatitude ? Number(session.lastLatitude) : null;\n  let prevLng = session.lastLongitude ? Number(session.lastLongitude) : null;\n\n  const sorted = [...points].sort(\n    (a, b) => new Date(a.recordedAt).getTime() - new Date(b.recordedAt).getTime()\n  );\n\n  for (const p of sorted) {\n    if (prevLat != null && prevLng != null) {\n      const d = haversineDistance(prevLat, prevLng, p.latitude, p.longitude);\n      if (d <= MAX_DISTANCE_JUMP_M) {\n        addedDistance += d;\n      }\n    }\n    prevLat = p.latitude;\n    prevLng = p.longitude;\n  }\n\n  const lastPoint = sorted[sorted.length - 1]!;\n\n  // Update session summary\n  await prisma.trackingSession.update({\n    where: { id: sessionId },\n    data: {\n      totalPoints: { increment: points.length },\n      totalDistanceM: { increment: addedDistance },\n      lastLatitude: new Prisma.Decimal(lastPoint.latitude),\n      lastLongitude: new Prisma.Decimal(lastPoint.longitude),\n      lastRecordedAt: new Date(lastPoint.recordedAt),\n    },\n  });\n\n  return { accepted: points.length, distance: addedDistance };\n}\n
"},{"location":"v2/features/map/tracking/#gps-auto-tracking-frontend","title":"GPS Auto-Tracking (Frontend)","text":"
// admin/src/components/canvass/GPSTracker.tsx\nuseEffect(() => {\n  if (!trackingSessionId || !enabled) return;\n\n  const pointsBuffer: TrackPoint[] = [];\n\n  const watchId = navigator.geolocation.watchPosition(\n    (position) => {\n      const point = {\n        latitude: position.coords.latitude,\n        longitude: position.coords.longitude,\n        accuracy: position.coords.accuracy,\n        recordedAt: new Date().toISOString(),\n      };\n\n      pointsBuffer.push(point);\n      setCurrentPosition([point.latitude, point.longitude]);\n    },\n    (error) => {\n      console.error('GPS error:', error);\n      message.error('GPS tracking failed');\n    },\n    {\n      enableHighAccuracy: true,\n      maximumAge: 0,\n      timeout: 10000,\n    }\n  );\n\n  // Submit buffered points every 10 seconds\n  const interval = setInterval(async () => {\n    if (pointsBuffer.length === 0) return;\n\n    try {\n      await api.post(`/map/tracking/sessions/${trackingSessionId}/points`, {\n        points: pointsBuffer.splice(0), // Drain buffer\n      });\n    } catch (error) {\n      console.error('Failed to submit GPS points:', error);\n    }\n  }, 10000);\n\n  return () => {\n    navigator.geolocation.clearWatch(watchId);\n    clearInterval(interval);\n  };\n}, [trackingSessionId, enabled]);\n
"},{"location":"v2/features/map/tracking/#route-visualization-frontend","title":"Route Visualization (Frontend)","text":"
// admin/src/pages/volunteer/MyRoutesPage.tsx\nconst fetchRoute = async (sessionId: string) => {\n  const { data } = await api.get(`/map/tracking/sessions/${sessionId}/route`);\n\n  // Convert TrackPoints to polyline coordinates\n  const polyline = data.points.map((p: TrackPoint) => [p.latitude, p.longitude]);\n\n  // Extract event markers\n  const events = data.points\n    .filter((p: TrackPoint) => p.eventType)\n    .map((p: TrackPoint) => ({\n      position: [p.latitude, p.longitude],\n      eventType: p.eventType,\n      recordedAt: p.recordedAt,\n    }));\n\n  setRoute({ polyline, events, distance: data.totalDistanceM });\n};\n\n// Render route\n<Polyline positions={route.polyline} pathOptions={{ color: '#3498db', weight: 3 }} />\n{route.events.map((event, i) => (\n  <Marker\n    key={i}\n    position={event.position}\n    icon={getEventIcon(event.eventType)}\n  >\n    <Popup>{event.eventType} - {dayjs(event.recordedAt).format('HH:mm')}</Popup>\n  </Marker>\n))}\n
"},{"location":"v2/features/map/tracking/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/tracking/#issue-gps-tracking-draining-battery","title":"Issue: GPS Tracking Draining Battery","text":"

Solutions:

  1. Reduce accuracy: enableHighAccuracy: false
  2. Increase submit interval: SUBMIT_INTERVAL_MS = 30000 (30s)
  3. Add pause/resume tracking buttons
"},{"location":"v2/features/map/tracking/#issue-distance-calculation-incorrect","title":"Issue: Distance Calculation Incorrect","text":"

Symptoms: Total distance much higher than expected

Causes: GPS glitches causing large jumps

Solutions:

Increase MAX_DISTANCE_JUMP_M threshold to ignore outliers:

const MAX_DISTANCE_JUMP_M = 2000; // Was 1000, increase to 2000\n
"},{"location":"v2/features/map/tracking/#issue-route-polyline-jagged","title":"Issue: Route Polyline Jagged","text":"

Symptoms: Route looks zigzag instead of smooth

Causes: GPS accuracy poor (\u00b120m)

Solutions:

Apply smoothing algorithm to polyline:

import { simplify } from '@turf/turf';\n\nconst smoothed = simplify(polyline, { tolerance: 0.0001, highQuality: true });\n
"},{"location":"v2/features/map/tracking/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/tracking/#batch-point-insertion","title":"Batch Point Insertion","text":"

Efficient Bulk Insert:

// Insert all points in single transaction\nawait prisma.trackPoint.createMany({\n  data: points.map((p) => ({ ... })),\n});\n\n// Avoid N+1: single UPDATE instead of N UPDATEs\nawait prisma.trackingSession.update({\n  where: { id: sessionId },\n  data: {\n    totalPoints: { increment: points.length },\n    totalDistanceM: { increment: totalDistance },\n  },\n});\n
"},{"location":"v2/features/map/tracking/#query-optimization","title":"Query Optimization","text":"

Index for Route Queries:

CREATE INDEX idx_track_points_session_time ON \"TrackPoint\" (\"trackingSessionId\", \"recordedAt\");\n

Efficient Route Query:

const points = await prisma.trackPoint.findMany({\n  where: { trackingSessionId: sessionId },\n  orderBy: { recordedAt: 'asc' },\n  select: { latitude: true, longitude: true, recordedAt: true, eventType: true },\n});\n
"},{"location":"v2/features/map/tracking/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/map/walk-sheets/","title":"Walk Sheets & QR Codes","text":""},{"location":"v2/features/map/walk-sheets/#overview","title":"Overview","text":"

The Walk Sheets system provides printable door-to-door canvassing materials with integrated QR code support. This feature enables campaign organizers to generate professional walk sheets for volunteers, complete with address lists, cut boundaries, and quick-access QR codes to campaign resources.

Key Features:

Use Cases:

Architecture Highlights:

"},{"location":"v2/features/map/walk-sheets/#architecture","title":"Architecture","text":"
flowchart TB\n    subgraph Admin Interface\n        Admin[Admin User]\n        Settings[MapSettingsPage]\n        WalkSheet[WalkSheetPage]\n        CutExport[CutExportPage]\n    end\n\n    subgraph API Layer\n        MapSettingsAPI[\"/api/map-settings\"]\n        CutsAPI[\"/api/cuts/:id\"]\n        LocationsAPI[\"/api/locations?cutId=\"]\n        QRAPI[\"/api/qr/generate\"]\n    end\n\n    subgraph Database\n        MapSettingsDB[(MapSettings)]\n        CutsDB[(Cuts)]\n        LocationsDB[(Locations)]\n    end\n\n    subgraph Print System\n        Preview[Print Preview]\n        Browser[Browser Print Dialog]\n        PDF[PDF Output]\n    end\n\n    Admin --> Settings\n    Admin --> WalkSheet\n    Admin --> CutExport\n\n    Settings --> MapSettingsAPI\n    WalkSheet --> MapSettingsAPI\n    WalkSheet --> CutsAPI\n    WalkSheet --> LocationsAPI\n    WalkSheet --> QRAPI\n    CutExport --> CutsAPI\n    CutExport --> LocationsAPI\n\n    MapSettingsAPI --> MapSettingsDB\n    CutsAPI --> CutsDB\n    LocationsAPI --> LocationsDB\n\n    WalkSheet --> Preview\n    CutExport --> Preview\n    Preview --> Browser\n    Browser --> PDF\n\n    QRAPI --> QRGen[QR Code PNG Generator]\n    QRGen --> Base64[Base64 Data URL]\n    Base64 --> WalkSheet

Data Flow:

  1. Configuration Phase:
  2. Admin configures walk sheet settings (title, subtitle, footer, QR codes)
  3. Settings stored in MapSettings singleton
  4. QR code URLs and labels defined (up to 3)

  5. Generation Phase:

  6. Admin selects cut from dropdown
  7. Frontend fetches cut details and settings
  8. Point-in-polygon filter retrieves locations within cut
  9. QR codes generated via POST /api/qr/generate
  10. Walk sheet rendered with all components

  11. Print Phase:

  12. window.print() triggered
  13. Browser print dialog opens
  14. Print CSS rules applied (hide nav, adjust layout)
  15. User selects printer or \"Save as PDF\"
"},{"location":"v2/features/map/walk-sheets/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/walk-sheets/#mapsettings-model","title":"MapSettings Model","text":"
model MapSettings {\n  id        Int      @id @default(1) // Singleton\n\n  // Walk Sheet Configuration\n  walkSheetTitle    String  @default(\"Walk Sheet\")\n  walkSheetSubtitle String  @default(\"\")\n  walkSheetFooter   String  @default(\"\")\n\n  // QR Code 1\n  qrCode1Url   String?\n  qrCode1Label String?\n\n  // QR Code 2\n  qrCode2Url   String?\n  qrCode2Label String?\n\n  // QR Code 3\n  qrCode3Url   String?\n  qrCode3Label String?\n\n  // Other map settings\n  defaultCenterLat Float   @default(43.6532)\n  defaultCenterLng Float   @default(-79.3832)\n  defaultZoom      Int     @default(12)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n}\n

Singleton Pattern: - Always ID = 1 - Created during seed if not exists - Single source of truth for walk sheet config

"},{"location":"v2/features/map/walk-sheets/#cut-model","title":"Cut Model","text":"
model Cut {\n  id          Int      @id @default(autoincrement())\n  name        String\n  description String?\n  geojson     Json     // GeoJSON Polygon or MultiPolygon\n  color       String   @default(\"#3498db\")\n  visible     Boolean  @default(true)\n\n  shifts      Shift[]\n\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n}\n

GeoJSON Structure:

{\n  \"type\": \"Polygon\",\n  \"coordinates\": [\n    [\n      [-79.38, 43.65],\n      [-79.37, 43.65],\n      [-79.37, 43.66],\n      [-79.38, 43.66],\n      [-79.38, 43.65]\n    ]\n  ]\n}\n

"},{"location":"v2/features/map/walk-sheets/#location-model","title":"Location Model","text":"
model Location {\n  id          Int      @id @default(autoincrement())\n  address     String\n  latitude    Float?\n  longitude   Float?\n  postalCode  String?\n  province    String?\n\n  // Geocoding metadata\n  geocodeConfidence Int?        // 0-100\n  geocodeProvider   String?     // GOOGLE, MAPBOX, etc.\n\n  // NAR import fields\n  locGuid           String?  @unique\n  federalDistrict   String?\n  buildingUse       Int?     // 1 = Residential\n\n  addresses   Address[]\n\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n}\n
"},{"location":"v2/features/map/walk-sheets/#address-model","title":"Address Model","text":"
model Address {\n  id         Int      @id @default(autoincrement())\n  locationId Int\n  location   Location @relation(fields: [locationId], references: [id], onDelete: Cascade)\n\n  unitNumber   String?\n  firstName    String?\n  lastName     String?\n  supportLevel Int?     // 1-5 scale\n  notes        String?\n\n  // NAR import\n  addrGuid String? @unique\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([locationId])\n}\n

Support Level Scale: - 1 = Strong Opposition - 2 = Lean Opposition - 3 = Undecided - 4 = Lean Support - 5 = Strong Support

"},{"location":"v2/features/map/walk-sheets/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/map/walk-sheets/#get-apimap-settings","title":"GET /api/map-settings","text":"

Fetch walk sheet configuration.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Response:

{\n  \"id\": 1,\n  \"walkSheetTitle\": \"Toronto Canvass Walk Sheet\",\n  \"walkSheetSubtitle\": \"Ward 10 - November 2025\",\n  \"walkSheetFooter\": \"Questions? Call HQ at 416-555-1234\",\n  \"qrCode1Url\": \"https://example.com/campaign\",\n  \"qrCode1Label\": \"Campaign Page\",\n  \"qrCode2Url\": \"https://example.com/volunteer\",\n  \"qrCode2Label\": \"Volunteer Portal\",\n  \"qrCode3Url\": \"https://example.com/donate\",\n  \"qrCode3Label\": \"Donate Now\",\n  \"defaultCenterLat\": 43.6532,\n  \"defaultCenterLng\": -79.3832,\n  \"defaultZoom\": 12,\n  \"createdAt\": \"2025-01-15T10:00:00Z\",\n  \"updatedAt\": \"2025-02-10T14:30:00Z\"\n}\n

"},{"location":"v2/features/map/walk-sheets/#put-apimap-settings","title":"PUT /api/map-settings","text":"

Update walk sheet configuration.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Request Body:

{\n  \"walkSheetTitle\": \"Updated Title\",\n  \"walkSheetSubtitle\": \"Updated Subtitle\",\n  \"walkSheetFooter\": \"Updated footer text with contact info\",\n  \"qrCode1Url\": \"https://newurl.com\",\n  \"qrCode1Label\": \"New Label\"\n}\n

Response: Updated MapSettings object

Validation: - walkSheetTitle: 1-100 characters - walkSheetSubtitle: 0-200 characters - walkSheetFooter: 0-500 characters - qrCode URLs: valid HTTP/HTTPS URLs - qrCode labels: 0-50 characters

"},{"location":"v2/features/map/walk-sheets/#get-apicutsid","title":"GET /api/cuts/:id","text":"

Fetch cut details for walk sheet.

Authentication: Required

Response:

{\n  \"id\": 42,\n  \"name\": \"Downtown Core\",\n  \"description\": \"High-density residential area\",\n  \"geojson\": {\n    \"type\": \"Polygon\",\n    \"coordinates\": [[...]]\n  },\n  \"color\": \"#3498db\",\n  \"visible\": true,\n  \"createdAt\": \"2025-01-20T09:00:00Z\",\n  \"updatedAt\": \"2025-02-01T11:00:00Z\"\n}\n

"},{"location":"v2/features/map/walk-sheets/#get-apilocationscutidid","title":"GET /api/locations?cutId=:id","text":"

Fetch locations within cut boundary.

Authentication: Required

Query Parameters: - cutId (required): Cut ID for filtering - sortBy (optional): Field to sort by (default: \"address\") - order (optional): \"asc\" or \"desc\" (default: \"asc\")

Response:

{\n  \"data\": [\n    {\n      \"id\": 1001,\n      \"address\": \"123 Main St\",\n      \"latitude\": 43.6532,\n      \"longitude\": -79.3832,\n      \"postalCode\": \"M5H 2N2\",\n      \"addresses\": [\n        {\n          \"id\": 5001,\n          \"unitNumber\": \"101\",\n          \"firstName\": \"John\",\n          \"lastName\": \"Smith\",\n          \"supportLevel\": 4,\n          \"notes\": \"Lawn sign requested\"\n        },\n        {\n          \"id\": 5002,\n          \"unitNumber\": \"102\",\n          \"firstName\": \"Jane\",\n          \"lastName\": \"Doe\",\n          \"supportLevel\": 5,\n          \"notes\": null\n        }\n      ]\n    }\n  ],\n  \"total\": 150\n}\n

Filtering Logic:

// Point-in-polygon filter\nconst locations = await prisma.location.findMany({\n  where: {\n    AND: [\n      { latitude: { not: null } },\n      { longitude: { not: null } }\n    ]\n  },\n  include: {\n    addresses: {\n      orderBy: { unitNumber: 'asc' }\n    }\n  },\n  orderBy: { address: 'asc' }\n});\n\n// Filter using point-in-polygon\nconst filtered = locations.filter(loc =>\n  isPointInPolygon([loc.longitude!, loc.latitude!], cut.geojson)\n);\n

"},{"location":"v2/features/map/walk-sheets/#post-apiqrgenerate","title":"POST /api/qr/generate","text":"

Generate QR code PNG from URL.

Authentication: None (public endpoint)

Request Body:

{\n  \"url\": \"https://example.com/campaign\",\n  \"size\": 200\n}\n

Parameters: - url (required): Target URL for QR code - size (optional): QR code dimension in pixels (default: 200, max: 500)

Response:

{\n  \"png\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...\"\n}\n

Error Responses: - 400: Invalid URL format - 400: Size must be between 50-500 - 500: QR code generation failed

Rate Limiting: 100 requests per 15 minutes per IP

"},{"location":"v2/features/map/walk-sheets/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/walk-sheets/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description N/A Walk sheet settings stored in database"},{"location":"v2/features/map/walk-sheets/#mapsettings-configuration","title":"MapSettings Configuration","text":"

Access via: Admin \u2192 Settings \u2192 Map Settings

Setting Type Default Max Length Description walkSheetTitle string \"Walk Sheet\" 100 Header title for walk sheets walkSheetSubtitle string \"\" 200 Subtitle below title (ward, date, etc.) walkSheetFooter string \"\" 500 Footer text (contact info, instructions) qrCode1Url string null 2048 First QR code target URL qrCode1Label string null 50 First QR code label qrCode2Url string null 2048 Second QR code target URL qrCode2Label string null 50 Second QR code label qrCode3Url string null 2048 Third QR code target URL qrCode3Label string null 50 Third QR code label

QR Code URL Examples: - Campaign page: https://example.com/campaigns/123 - Volunteer portal: https://example.com/volunteer - Donation page: https://example.com/donate - Social media: https://facebook.com/campaignpage - Google Form: https://forms.google.com/...

QR Code Label Best Practices: - Keep short (2-4 words) - Action-oriented (\"Donate Now\", \"Get Updates\") - Mobile-friendly (scanned on phones) - Clear purpose (\"Campaign Details\", \"Volunteer Info\")

"},{"location":"v2/features/map/walk-sheets/#print-configuration","title":"Print Configuration","text":"

CSS Variables:

@media print {\n  --print-margin: 0.5in;\n  --print-font-size: 10pt;\n  --print-header-size: 16pt;\n  --print-qr-size: 150px;\n  --print-table-border: 1px solid #000;\n}\n

Page Setup: - Size: A4 (210mm \u00d7 297mm) or Letter (8.5\" \u00d7 11\") - Orientation: Portrait - Margins: 0.5 inches (12.7mm) - Print background: Enabled (for borders) - Scale: 100% (no auto-fit)

"},{"location":"v2/features/map/walk-sheets/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/map/walk-sheets/#configure-walk-sheet-settings","title":"Configure Walk Sheet Settings","text":"

Step 1: Navigate to Map Settings

  1. Log in as SUPER_ADMIN or MAP_ADMIN
  2. Click Settings in sidebar
  3. Click Map Settings submenu
  4. Scroll to \"Walk Sheet Configuration\" section

Step 2: Set Title and Subtitle

Walk Sheet Title: \"Toronto Canvass Walk Sheet\"\nWalk Sheet Subtitle: \"Ward 10 - November 2025 Campaign\"\n

Step 3: Configure QR Codes

QR Code 1:\n  URL: https://example.com/campaign/123\n  Label: Campaign Page\n\nQR Code 2:\n  URL: https://example.com/volunteer\n  Label: Volunteer Sign-Up\n\nQR Code 3:\n  URL: https://example.com/donate\n  Label: Donate Now\n

Step 4: Set Footer Text

Walk Sheet Footer:\n  Questions? Call HQ at 416-555-1234\n  Emergency? Text volunteer coordinator at 416-555-5678\n  Return completed sheets to campaign office by 8 PM\n

Step 5: Save Settings

"},{"location":"v2/features/map/walk-sheets/#generate-walk-sheet","title":"Generate Walk Sheet","text":"

Step 1: Navigate to Walk Sheet Page

  1. Click Map in sidebar
  2. Click Walk Sheet submenu
  3. Walk sheet generator page loads

Step 2: Select Cut

  1. Click Select Cut dropdown
  2. Choose cut from list (e.g., \"Downtown Core\")
  3. Loading indicator shows while fetching locations
  4. Location count displayed (e.g., \"150 locations\")

Step 3: Preview Walk Sheet

Walk sheet displays:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502  Toronto Canvass Walk Sheet                 \u2502\n\u2502  Ward 10 - November 2025 Campaign           \u2502\n\u2502  Cut: Downtown Core                         \u2502\n\u2502  Date: February 13, 2026                    \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Address           \u2502 Unit \u2502 Notes  \u2502 Visited \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 100 Adelaide St E \u2502 101  \u2502 Lawn   \u2502    \u25a1    \u2502\n\u2502 100 Adelaide St E \u2502 102  \u2502        \u2502    \u25a1    \u2502\n\u2502 102 Adelaide St E \u2502      \u2502        \u2502    \u25a1    \u2502\n\u2502 105 Bay St        \u2502 1A   \u2502 Strong \u2502    \u25a1    \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\n[QR Code]        [QR Code]        [QR Code]\nCampaign Page    Volunteer Info    Donate Now\n\nQuestions? Call HQ at 416-555-1234\nEmergency? Text volunteer coordinator at 416-555-5678\nReturn completed sheets to campaign office by 8 PM\n

Step 4: Print Walk Sheet

  1. Click Print button (top-right corner)
  2. Browser print dialog opens
  3. Configure print settings:
  4. Destination: Printer or \"Save as PDF\"
  5. Pages: All
  6. Layout: Portrait
  7. Margins: Default
  8. Background graphics: Enabled
  9. Click Print or Save

Step 5: Distribute to Volunteers

"},{"location":"v2/features/map/walk-sheets/#generate-cut-export-report","title":"Generate Cut Export Report","text":"

Step 1: Navigate to Cuts Page

  1. Click Map \u2192 Cuts in sidebar
  2. Cuts table loads with list of all cuts

Step 2: Open Cut Export

  1. Find cut row (e.g., \"Downtown Core\")
  2. Click Export button in Actions column
  3. New tab opens with export report

Step 3: Review Statistics

Export report shows:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502  Cut Export Report                          \u2502\n\u2502  Cut: Downtown Core                         \u2502\n\u2502  Generated: February 13, 2026 10:30 AM      \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\nStatistics:\n  Total Locations: 150\n  Total Units: 287\n  Residential: 280 (97.6%)\n  Commercial: 7 (2.4%)\n  Geocoded: 148 (98.7%)\n  Missing Coordinates: 2 (1.3%)\n\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Address         \u2502 Lat  \u2502 Lng   \u2502 Units    \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 100 Adelaide E  \u2502 43.6 \u2502 -79.3 \u2502 2        \u2502\n\u2502 102 Adelaide E  \u2502 43.6 \u2502 -79.3 \u2502 1        \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\n[Export CSV Button]  [Print Button]\n

Step 4: Export to CSV

  1. Click Export CSV button
  2. File downloads: cut-42-downtown-core-2026-02-13.csv
  3. Open in spreadsheet for further analysis

CSV Format:

Address,Latitude,Longitude,Postal Code,Units,Residential\n\"100 Adelaide St E\",43.6532,-79.3832,\"M5H 2N2\",2,true\n\"102 Adelaide St E\",43.6540,-79.3825,\"M5H 2N3\",1,true\n

"},{"location":"v2/features/map/walk-sheets/#print-layout","title":"Print Layout","text":""},{"location":"v2/features/map/walk-sheets/#page-structure","title":"Page Structure","text":"
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 [HEADER SECTION]                            \u2502\n\u2502   - Walk Sheet Title                        \u2502\n\u2502   - Subtitle                                \u2502\n\u2502   - Cut Name                                \u2502\n\u2502   - Generated Date                          \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 [ADDRESS TABLE]                             \u2502\n\u2502   - Sortable by street name                \u2502\n\u2502   - Multi-unit grouped                      \u2502\n\u2502   - Support level indicators               \u2502\n\u2502   - Notes column                            \u2502\n\u2502   - Visited checkbox                        \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 [QR CODE SECTION]                           \u2502\n\u2502   - Up to 3 QR codes                        \u2502\n\u2502   - Labels below each code                  \u2502\n\u2502   - Horizontal layout                       \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 [FOOTER SECTION]                            \u2502\n\u2502   - Custom footer text                      \u2502\n\u2502   - Contact information                     \u2502\n\u2502   - Instructions                            \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n
"},{"location":"v2/features/map/walk-sheets/#css-print-rules","title":"CSS Print Rules","text":"

Component: WalkSheetPage.tsx

@media print {\n  /* Hide non-printable elements */\n  .no-print,\n  .ant-layout-header,\n  .ant-layout-sider,\n  button,\n  .ant-select,\n  .ant-form,\n  nav {\n    display: none !important;\n  }\n\n  /* Page setup */\n  @page {\n    size: A4 portrait;\n    margin: 0.5in;\n  }\n\n  body {\n    font-size: 10pt;\n    line-height: 1.4;\n    color: #000;\n    background: #fff;\n  }\n\n  /* Header styling */\n  .walk-sheet-header {\n    text-align: center;\n    margin-bottom: 20px;\n    border-bottom: 2px solid #000;\n    padding-bottom: 10px;\n  }\n\n  .walk-sheet-title {\n    font-size: 16pt;\n    font-weight: bold;\n    margin-bottom: 5px;\n  }\n\n  .walk-sheet-subtitle {\n    font-size: 12pt;\n    color: #333;\n  }\n\n  /* Table styling */\n  table {\n    width: 100%;\n    border-collapse: collapse;\n    page-break-inside: avoid;\n    margin-bottom: 20px;\n  }\n\n  th, td {\n    border: 1px solid #000;\n    padding: 6px;\n    text-align: left;\n  }\n\n  th {\n    background-color: #f0f0f0;\n    font-weight: bold;\n    font-size: 9pt;\n  }\n\n  td {\n    font-size: 9pt;\n  }\n\n  /* Prevent row breaks */\n  tr {\n    page-break-inside: avoid;\n  }\n\n  /* QR code section */\n  .qr-code-section {\n    display: flex;\n    justify-content: space-around;\n    margin: 20px 0;\n    page-break-inside: avoid;\n  }\n\n  .qr-code-item {\n    text-align: center;\n    width: 150px;\n  }\n\n  .qr-code-item img {\n    width: 150px;\n    height: 150px;\n    margin-bottom: 5px;\n  }\n\n  .qr-code-label {\n    font-size: 9pt;\n    font-weight: bold;\n  }\n\n  /* Footer styling */\n  .walk-sheet-footer {\n    margin-top: 20px;\n    padding-top: 10px;\n    border-top: 1px solid #000;\n    font-size: 9pt;\n    white-space: pre-wrap;\n  }\n\n  /* Checkbox styling */\n  .visited-checkbox {\n    width: 15px;\n    height: 15px;\n    border: 1px solid #000;\n    display: inline-block;\n  }\n\n  /* Support level indicators */\n  .support-level-1 { color: #e74c3c; } /* Strong Opposition */\n  .support-level-2 { color: #f39c12; } /* Lean Opposition */\n  .support-level-3 { color: #95a5a6; } /* Undecided */\n  .support-level-4 { color: #3498db; } /* Lean Support */\n  .support-level-5 { color: #27ae60; } /* Strong Support */\n}\n
"},{"location":"v2/features/map/walk-sheets/#address-table-layout","title":"Address Table Layout","text":"

Column Structure:

Column Width Content Sort Address 40% Street address Alphabetical Unit 10% Unit/apartment number Alphanumeric Name 20% First + Last name Alphabetical Support 10% Support level (1-5) Color-coded Notes 15% Canvasser notes N/A Visited 5% Checkbox N/A

Multi-Unit Grouping:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Address           \u2502 Unit \u2502 Name       \u2502 Support \u2502 Notes  \u2502 Visited \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 100 Adelaide St E \u2502 101  \u2502 John Smith \u2502    4    \u2502 Lawn   \u2502    \u25a1    \u2502\n\u2502 100 Adelaide St E \u2502 102  \u2502 Jane Doe   \u2502    5    \u2502        \u2502    \u25a1    \u2502\n\u2502 100 Adelaide St E \u2502 103  \u2502            \u2502         \u2502        \u2502    \u25a1    \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 102 Adelaide St E \u2502      \u2502            \u2502         \u2502        \u2502    \u25a1    \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n

Support Level Colors: - 1 (Strong Opposition): Red (#e74c3c) - 2 (Lean Opposition): Orange (#f39c12) - 3 (Undecided): Gray (#95a5a6) - 4 (Lean Support): Blue (#3498db) - 5 (Strong Support): Green (#27ae60)

"},{"location":"v2/features/map/walk-sheets/#qr-code-layout","title":"QR Code Layout","text":"

Horizontal Layout:

    [QR 150\u00d7150]         [QR 150\u00d7150]         [QR 150\u00d7150]\n    Campaign Page        Volunteer Info        Donate Now\n

QR Code Generation: - Size: 150\u00d7150 pixels - Error correction: Medium (M) - Format: PNG with transparent background - Encoding: UTF-8 - Margin: 4 modules

Spacing: - Between codes: 30px - Above section: 20px - Below section: 20px - Label margin: 5px

"},{"location":"v2/features/map/walk-sheets/#cut-export-page","title":"Cut Export Page","text":""},{"location":"v2/features/map/walk-sheets/#export-report-structure","title":"Export Report Structure","text":"

Component: CutExportPage.tsx

Route: /app/map/cuts/:id/export

Layout:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502  Cut Export Report                          \u2502\n\u2502  [Cut Name]                                 \u2502\n\u2502  Generated: [Date Time]                     \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502  STATISTICS PANEL                           \u2502\n\u2502  \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510            \u2502\n\u2502  \u2502 Total Locs   \u2502 Geocoded     \u2502            \u2502\n\u2502  \u2502 150          \u2502 148 (98.7%)  \u2502            \u2502\n\u2502  \u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524            \u2502\n\u2502  \u2502 Total Units  \u2502 Residential  \u2502            \u2502\n\u2502  \u2502 287          \u2502 280 (97.6%)  \u2502            \u2502\n\u2502  \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518            \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502  LOCATION TABLE                             \u2502\n\u2502  [Sortable, filterable table]              \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502  ACTIONS                                    \u2502\n\u2502  [Export CSV] [Print]                       \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n
"},{"location":"v2/features/map/walk-sheets/#statistics-panel","title":"Statistics Panel","text":"

Metrics Displayed:

  1. Total Locations: Count of locations within cut
  2. Total Units: Sum of addresses across all locations
  3. Geocoded Locations: Locations with lat/lng (% of total)
  4. Missing Coordinates: Locations without lat/lng
  5. Residential Units: Units with buildingUse = 1
  6. Commercial Units: Units with buildingUse != 1
  7. Support Level Breakdown: Count by level (1-5)
  8. Cut Area: Approximate area in square kilometers

Statistics Calculation:

interface CutStatistics {\n  totalLocations: number;\n  totalUnits: number;\n  geocodedCount: number;\n  geocodedPercent: number;\n  missingCoordinates: number;\n  residentialCount: number;\n  residentialPercent: number;\n  commercialCount: number;\n  supportLevelBreakdown: Record<number, number>;\n  cutAreaKm2: number;\n}\n\nconst calculateStats = (locations: Location[]): CutStatistics => {\n  const totalLocations = locations.length;\n  const totalUnits = locations.reduce((sum, loc) =>\n    sum + loc.addresses.length, 0);\n  const geocodedCount = locations.filter(loc =>\n    loc.latitude && loc.longitude).length;\n  const residentialCount = locations.filter(loc =>\n    loc.buildingUse === 1).length;\n\n  const supportLevelBreakdown = {};\n  locations.forEach(loc => {\n    loc.addresses.forEach(addr => {\n      if (addr.supportLevel) {\n        supportLevelBreakdown[addr.supportLevel] =\n          (supportLevelBreakdown[addr.supportLevel] || 0) + 1;\n      }\n    });\n  });\n\n  return {\n    totalLocations,\n    totalUnits,\n    geocodedCount,\n    geocodedPercent: (geocodedCount / totalLocations) * 100,\n    missingCoordinates: totalLocations - geocodedCount,\n    residentialCount,\n    residentialPercent: (residentialCount / totalLocations) * 100,\n    commercialCount: totalLocations - residentialCount,\n    supportLevelBreakdown,\n    cutAreaKm2: calculatePolygonArea(cut.geojson)\n  };\n};\n
"},{"location":"v2/features/map/walk-sheets/#location-table","title":"Location Table","text":"

Columns:

Column Data Format Address location.address String Latitude location.latitude 6 decimals Longitude location.longitude 6 decimals Postal Code location.postalCode Uppercase Units addresses.length Integer Residential buildingUse === 1 Boolean Support Avg avg(addresses.supportLevel) 1 decimal

Table Features:

"},{"location":"v2/features/map/walk-sheets/#csv-export","title":"CSV Export","text":"

Export Button Handler:

const exportToCSV = () => {\n  const headers = [\n    'Address',\n    'Latitude',\n    'Longitude',\n    'Postal Code',\n    'Units',\n    'Residential',\n    'Support Average',\n    'Federal District'\n  ];\n\n  const rows = locations.map(loc => [\n    loc.address,\n    loc.latitude?.toFixed(6) || '',\n    loc.longitude?.toFixed(6) || '',\n    loc.postalCode || '',\n    loc.addresses.length,\n    loc.buildingUse === 1 ? 'Yes' : 'No',\n    calculateAverageSupportLevel(loc.addresses).toFixed(1),\n    loc.federalDistrict || ''\n  ]);\n\n  const csv = [headers, ...rows]\n    .map(row => row.map(cell => `\"${cell}\"`).join(','))\n    .join('\\n');\n\n  const blob = new Blob([csv], { type: 'text/csv' });\n  const url = URL.createObjectURL(blob);\n  const link = document.createElement('a');\n  link.href = url;\n  link.download = `cut-${cutId}-${cutName}-${new Date().toISOString().split('T')[0]}.csv`;\n  link.click();\n  URL.revokeObjectURL(url);\n};\n

CSV Output Example:

\"Address\",\"Latitude\",\"Longitude\",\"Postal Code\",\"Units\",\"Residential\",\"Support Average\",\"Federal District\"\n\"100 Adelaide St E\",\"43.653200\",\"-79.383200\",\"M5H 2N2\",\"2\",\"Yes\",\"4.5\",\"Toronto Centre\"\n\"102 Adelaide St E\",\"43.654000\",\"-79.382500\",\"M5H 2N3\",\"1\",\"Yes\",\"3.0\",\"Toronto Centre\"\n\"105 Bay St\",\"43.650000\",\"-79.380000\",\"M5J 2R8\",\"12\",\"Yes\",\"4.2\",\"Toronto Centre\"\n
"},{"location":"v2/features/map/walk-sheets/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/walk-sheets/#walksheetpagetsx-component-structure","title":"WalkSheetPage.tsx - Component Structure","text":"
import React, { useEffect, useState } from 'react';\nimport { Select, Button, Table, Space, Spin, Typography, Row, Col } from 'antd';\nimport { PrinterOutlined } from '@ant-design/icons';\nimport { api } from '@/lib/api';\nimport type { Cut, Location, MapSettings } from '@/types/api';\n\nconst { Title, Text } = Typography;\n\nconst WalkSheetPage: React.FC = () => {\n  const [cuts, setCuts] = useState<Cut[]>([]);\n  const [selectedCutId, setSelectedCutId] = useState<number | null>(null);\n  const [locations, setLocations] = useState<Location[]>([]);\n  const [settings, setSettings] = useState<MapSettings | null>(null);\n  const [qrCodes, setQrCodes] = useState<Record<number, string>>({});\n  const [loading, setLoading] = useState(false);\n\n  useEffect(() => {\n    fetchCuts();\n    fetchSettings();\n  }, []);\n\n  useEffect(() => {\n    if (selectedCutId) {\n      fetchLocations(selectedCutId);\n    }\n  }, [selectedCutId]);\n\n  useEffect(() => {\n    if (settings) {\n      generateQRCodes();\n    }\n  }, [settings]);\n\n  const fetchCuts = async () => {\n    const { data } = await api.get<Cut[]>('/cuts');\n    setCuts(data);\n  };\n\n  const fetchSettings = async () => {\n    const { data } = await api.get<MapSettings>('/map-settings');\n    setSettings(data);\n  };\n\n  const fetchLocations = async (cutId: number) => {\n    setLoading(true);\n    try {\n      const { data } = await api.get<{ data: Location[] }>(\n        `/locations?cutId=${cutId}&sortBy=address&order=asc`\n      );\n      setLocations(data.data);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const generateQRCodes = async () => {\n    if (!settings) return;\n\n    const codes: Record<number, string> = {};\n    const qrUrls = [\n      { url: settings.qrCode1Url, index: 1 },\n      { url: settings.qrCode2Url, index: 2 },\n      { url: settings.qrCode3Url, index: 3 }\n    ].filter(item => item.url);\n\n    for (const { url, index } of qrUrls) {\n      try {\n        const { data } = await api.post('/qr/generate', { url, size: 150 });\n        codes[index] = data.png;\n      } catch (error) {\n        console.error(`Failed to generate QR code ${index}:`, error);\n      }\n    }\n\n    setQrCodes(codes);\n  };\n\n  const handlePrint = () => {\n    window.print();\n  };\n\n  const columns = [\n    {\n      title: 'Address',\n      dataIndex: 'address',\n      key: 'address',\n      width: '40%'\n    },\n    {\n      title: 'Unit',\n      key: 'unit',\n      width: '10%',\n      render: (_: any, record: Location) => (\n        <Space direction=\"vertical\" size={0}>\n          {record.addresses.map(addr => (\n            <Text key={addr.id}>{addr.unitNumber || '-'}</Text>\n          ))}\n        </Space>\n      )\n    },\n    {\n      title: 'Name',\n      key: 'name',\n      width: '20%',\n      render: (_: any, record: Location) => (\n        <Space direction=\"vertical\" size={0}>\n          {record.addresses.map(addr => (\n            <Text key={addr.id}>\n              {addr.firstName && addr.lastName\n                ? `${addr.firstName} ${addr.lastName}`\n                : '-'}\n            </Text>\n          ))}\n        </Space>\n      )\n    },\n    {\n      title: 'Support',\n      key: 'support',\n      width: '10%',\n      render: (_: any, record: Location) => (\n        <Space direction=\"vertical\" size={0}>\n          {record.addresses.map(addr => (\n            <Text\n              key={addr.id}\n              className={addr.supportLevel ? `support-level-${addr.supportLevel}` : ''}\n            >\n              {addr.supportLevel || '-'}\n            </Text>\n          ))}\n        </Space>\n      )\n    },\n    {\n      title: 'Notes',\n      key: 'notes',\n      width: '15%',\n      render: (_: any, record: Location) => (\n        <Space direction=\"vertical\" size={0}>\n          {record.addresses.map(addr => (\n            <Text key={addr.id} ellipsis={{ tooltip: addr.notes }}>\n              {addr.notes || '-'}\n            </Text>\n          ))}\n        </Space>\n      )\n    },\n    {\n      title: 'Visited',\n      key: 'visited',\n      width: '5%',\n      render: (_: any, record: Location) => (\n        <Space direction=\"vertical\" size={0}>\n          {record.addresses.map(addr => (\n            <div key={addr.id} className=\"visited-checkbox\" />\n          ))}\n        </Space>\n      )\n    }\n  ];\n\n  const selectedCut = cuts.find(c => c.id === selectedCutId);\n\n  return (\n    <div className=\"walk-sheet-page\">\n      {/* Controls - hidden when printing */}\n      <div className=\"no-print\" style={{ marginBottom: 24 }}>\n        <Space>\n          <Select\n            style={{ width: 300 }}\n            placeholder=\"Select a cut\"\n            value={selectedCutId}\n            onChange={setSelectedCutId}\n            options={cuts.map(cut => ({\n              label: cut.name,\n              value: cut.id\n            }))}\n          />\n          <Button\n            type=\"primary\"\n            icon={<PrinterOutlined />}\n            onClick={handlePrint}\n            disabled={!selectedCutId || loading}\n          >\n            Print\n          </Button>\n        </Space>\n      </div>\n\n      {/* Walk Sheet Content - printed */}\n      {selectedCutId && settings && (\n        <>\n          {/* Header */}\n          <div className=\"walk-sheet-header\">\n            <Title level={2} className=\"walk-sheet-title\">\n              {settings.walkSheetTitle}\n            </Title>\n            {settings.walkSheetSubtitle && (\n              <Text className=\"walk-sheet-subtitle\">\n                {settings.walkSheetSubtitle}\n              </Text>\n            )}\n            <div style={{ marginTop: 8 }}>\n              <Text strong>Cut: </Text>\n              <Text>{selectedCut?.name}</Text>\n              <br />\n              <Text strong>Date: </Text>\n              <Text>{new Date().toLocaleDateString()}</Text>\n            </div>\n          </div>\n\n          {/* Address Table */}\n          {loading ? (\n            <div style={{ textAlign: 'center', padding: 40 }}>\n              <Spin size=\"large\" />\n            </div>\n          ) : (\n            <Table\n              dataSource={locations}\n              columns={columns}\n              pagination={false}\n              rowKey=\"id\"\n              bordered\n            />\n          )}\n\n          {/* QR Codes */}\n          {Object.keys(qrCodes).length > 0 && (\n            <Row gutter={16} className=\"qr-code-section\">\n              {[1, 2, 3].map(index => {\n                const qrUrl = settings[`qrCode${index}Url` as keyof MapSettings];\n                const qrLabel = settings[`qrCode${index}Label` as keyof MapSettings];\n                if (!qrUrl || !qrCodes[index]) return null;\n\n                return (\n                  <Col key={index} span={8} className=\"qr-code-item\">\n                    <img src={qrCodes[index]} alt={`QR Code ${index}`} />\n                    <div className=\"qr-code-label\">{qrLabel}</div>\n                  </Col>\n                );\n              })}\n            </Row>\n          )}\n\n          {/* Footer */}\n          {settings.walkSheetFooter && (\n            <div className=\"walk-sheet-footer\">\n              {settings.walkSheetFooter}\n            </div>\n          )}\n        </>\n      )}\n\n      {/* Print Styles */}\n      <style>{`\n        @media print {\n          .no-print {\n            display: none !important;\n          }\n\n          @page {\n            size: A4 portrait;\n            margin: 0.5in;\n          }\n\n          body {\n            font-size: 10pt;\n            line-height: 1.4;\n          }\n\n          .walk-sheet-header {\n            text-align: center;\n            margin-bottom: 20px;\n            border-bottom: 2px solid #000;\n            padding-bottom: 10px;\n          }\n\n          .walk-sheet-title {\n            font-size: 16pt !important;\n            margin-bottom: 5px !important;\n          }\n\n          .walk-sheet-subtitle {\n            font-size: 12pt;\n          }\n\n          table {\n            page-break-inside: avoid;\n          }\n\n          th, td {\n            font-size: 9pt !important;\n            padding: 6px !important;\n          }\n\n          .visited-checkbox {\n            width: 15px;\n            height: 15px;\n            border: 1px solid #000;\n            display: inline-block;\n          }\n\n          .support-level-1 { color: #e74c3c; }\n          .support-level-2 { color: #f39c12; }\n          .support-level-3 { color: #95a5a6; }\n          .support-level-4 { color: #3498db; }\n          .support-level-5 { color: #27ae60; }\n\n          .qr-code-section {\n            display: flex;\n            justify-content: space-around;\n            margin: 20px 0;\n            page-break-inside: avoid;\n          }\n\n          .qr-code-item {\n            text-align: center;\n          }\n\n          .qr-code-item img {\n            width: 150px;\n            height: 150px;\n          }\n\n          .qr-code-label {\n            font-size: 9pt;\n            font-weight: bold;\n            margin-top: 5px;\n          }\n\n          .walk-sheet-footer {\n            margin-top: 20px;\n            padding-top: 10px;\n            border-top: 1px solid #000;\n            font-size: 9pt;\n            white-space: pre-wrap;\n          }\n        }\n      `}</style>\n    </div>\n  );\n};\n\nexport default WalkSheetPage;\n
"},{"location":"v2/features/map/walk-sheets/#qr-code-api-qrroutests","title":"QR Code API - qr.routes.ts","text":"
import { Router } from 'express';\nimport QRCode from 'qrcode';\nimport { z } from 'zod';\nimport { validate } from '@/middleware/validate';\nimport rateLimit from 'express-rate-limit';\n\nconst router = Router();\n\n// Rate limiter: 100 requests per 15 minutes\nconst qrLimiter = rateLimit({\n  windowMs: 15 * 60 * 1000,\n  max: 100,\n  message: 'Too many QR code requests, please try again later'\n});\n\nconst generateQRSchema = z.object({\n  body: z.object({\n    url: z.string().url('Must be a valid URL'),\n    size: z.number().int().min(50).max(500).optional().default(200)\n  })\n});\n\n/**\n * POST /api/qr/generate\n * Generate QR code PNG from URL\n * Public endpoint (no authentication)\n */\nrouter.post(\n  '/generate',\n  qrLimiter,\n  validate(generateQRSchema),\n  async (req, res, next) => {\n    try {\n      const { url, size } = req.body;\n\n      // Generate QR code as data URL\n      const png = await QRCode.toDataURL(url, {\n        width: size,\n        margin: 4,\n        errorCorrectionLevel: 'M',\n        type: 'image/png'\n      });\n\n      res.json({ png });\n    } catch (error) {\n      next(error);\n    }\n  }\n);\n\nexport default router;\n
"},{"location":"v2/features/map/walk-sheets/#mapsettingspagetsx-qr-code-configuration","title":"MapSettingsPage.tsx - QR Code Configuration","text":"
import React, { useEffect } from 'react';\nimport { Form, Input, Button, message, Divider, Space, Typography } from 'antd';\nimport { api } from '@/lib/api';\nimport type { MapSettings } from '@/types/api';\n\nconst { Title, Text } = Typography;\n\nconst MapSettingsPage: React.FC = () => {\n  const [form] = Form.useForm();\n  const [loading, setLoading] = useState(false);\n\n  useEffect(() => {\n    fetchSettings();\n  }, []);\n\n  const fetchSettings = async () => {\n    setLoading(true);\n    try {\n      const { data } = await api.get<MapSettings>('/map-settings');\n      form.setFieldsValue(data);\n    } catch (error) {\n      message.error('Failed to load map settings');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const handleSubmit = async (values: Partial<MapSettings>) => {\n    setLoading(true);\n    try {\n      await api.put('/map-settings', values);\n      message.success('Settings saved successfully');\n    } catch (error) {\n      message.error('Failed to save settings');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  return (\n    <div>\n      <Title level={2}>Map Settings</Title>\n\n      <Form\n        form={form}\n        layout=\"vertical\"\n        onFinish={handleSubmit}\n        disabled={loading}\n      >\n        <Divider orientation=\"left\">Walk Sheet Configuration</Divider>\n\n        <Form.Item\n          label=\"Walk Sheet Title\"\n          name=\"walkSheetTitle\"\n          rules={[\n            { required: true, message: 'Title is required' },\n            { max: 100, message: 'Maximum 100 characters' }\n          ]}\n        >\n          <Input placeholder=\"Walk Sheet\" />\n        </Form.Item>\n\n        <Form.Item\n          label=\"Walk Sheet Subtitle\"\n          name=\"walkSheetSubtitle\"\n          rules={[{ max: 200, message: 'Maximum 200 characters' }]}\n        >\n          <Input placeholder=\"Ward 10 - November 2025\" />\n        </Form.Item>\n\n        <Form.Item\n          label=\"Walk Sheet Footer\"\n          name=\"walkSheetFooter\"\n          rules={[{ max: 500, message: 'Maximum 500 characters' }]}\n        >\n          <Input.TextArea\n            rows={4}\n            placeholder=\"Contact information, instructions, etc.\"\n          />\n        </Form.Item>\n\n        <Divider orientation=\"left\">QR Code 1</Divider>\n\n        <Form.Item\n          label=\"QR Code 1 URL\"\n          name=\"qrCode1Url\"\n          rules={[{ type: 'url', message: 'Must be a valid URL' }]}\n        >\n          <Input placeholder=\"https://example.com/campaign\" />\n        </Form.Item>\n\n        <Form.Item\n          label=\"QR Code 1 Label\"\n          name=\"qrCode1Label\"\n          rules={[{ max: 50, message: 'Maximum 50 characters' }]}\n        >\n          <Input placeholder=\"Campaign Page\" />\n        </Form.Item>\n\n        <Divider orientation=\"left\">QR Code 2</Divider>\n\n        <Form.Item\n          label=\"QR Code 2 URL\"\n          name=\"qrCode2Url\"\n          rules={[{ type: 'url', message: 'Must be a valid URL' }]}\n        >\n          <Input placeholder=\"https://example.com/volunteer\" />\n        </Form.Item>\n\n        <Form.Item\n          label=\"QR Code 2 Label\"\n          name=\"qrCode2Label\"\n          rules={[{ max: 50, message: 'Maximum 50 characters' }]}\n        >\n          <Input placeholder=\"Volunteer Info\" />\n        </Form.Item>\n\n        <Divider orientation=\"left\">QR Code 3</Divider>\n\n        <Form.Item\n          label=\"QR Code 3 URL\"\n          name=\"qrCode3Url\"\n          rules={[{ type: 'url', message: 'Must be a valid URL' }]}\n        >\n          <Input placeholder=\"https://example.com/donate\" />\n        </Form.Item>\n\n        <Form.Item\n          label=\"QR Code 3 Label\"\n          name=\"qrCode3Label\"\n          rules={[{ max: 50, message: 'Maximum 50 characters' }]}\n        >\n          <Input placeholder=\"Donate Now\" />\n        </Form.Item>\n\n        <Form.Item>\n          <Space>\n            <Button type=\"primary\" htmlType=\"submit\" loading={loading}>\n              Save Settings\n            </Button>\n            <Button onClick={fetchSettings}>Reset</Button>\n          </Space>\n        </Form.Item>\n      </Form>\n    </div>\n  );\n};\n\nexport default MapSettingsPage;\n
"},{"location":"v2/features/map/walk-sheets/#cutexportpagetsx-statistics-and-csv-export","title":"CutExportPage.tsx - Statistics and CSV Export","text":"
import React, { useEffect, useState } from 'react';\nimport { useParams } from 'react-router-dom';\nimport { Button, Table, Card, Row, Col, Statistic, Space, message } from 'antd';\nimport { PrinterOutlined, DownloadOutlined } from '@ant-design/icons';\nimport { api } from '@/lib/api';\nimport type { Cut, Location } from '@/types/api';\n\nconst CutExportPage: React.FC = () => {\n  const { id } = useParams<{ id: string }>();\n  const cutId = parseInt(id);\n\n  const [cut, setCut] = useState<Cut | null>(null);\n  const [locations, setLocations] = useState<Location[]>([]);\n  const [loading, setLoading] = useState(false);\n\n  useEffect(() => {\n    fetchData();\n  }, [cutId]);\n\n  const fetchData = async () => {\n    setLoading(true);\n    try {\n      const [cutRes, locsRes] = await Promise.all([\n        api.get<Cut>(`/cuts/${cutId}`),\n        api.get<{ data: Location[] }>(`/locations?cutId=${cutId}`)\n      ]);\n      setCut(cutRes.data);\n      setLocations(locsRes.data.data);\n    } catch (error) {\n      message.error('Failed to load cut data');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const calculateStats = () => {\n    const totalLocations = locations.length;\n    const totalUnits = locations.reduce((sum, loc) => sum + loc.addresses.length, 0);\n    const geocoded = locations.filter(loc => loc.latitude && loc.longitude).length;\n    const residential = locations.filter(loc => loc.buildingUse === 1).length;\n\n    return {\n      totalLocations,\n      totalUnits,\n      geocoded,\n      geocodedPercent: totalLocations > 0 ? (geocoded / totalLocations) * 100 : 0,\n      residential,\n      residentialPercent: totalLocations > 0 ? (residential / totalLocations) * 100 : 0\n    };\n  };\n\n  const exportToCSV = () => {\n    const headers = [\n      'Address',\n      'Latitude',\n      'Longitude',\n      'Postal Code',\n      'Units',\n      'Residential'\n    ];\n\n    const rows = locations.map(loc => [\n      loc.address,\n      loc.latitude?.toFixed(6) || '',\n      loc.longitude?.toFixed(6) || '',\n      loc.postalCode || '',\n      loc.addresses.length,\n      loc.buildingUse === 1 ? 'Yes' : 'No'\n    ]);\n\n    const csv = [headers, ...rows]\n      .map(row => row.map(cell => `\"${cell}\"`).join(','))\n      .join('\\n');\n\n    const blob = new Blob([csv], { type: 'text/csv' });\n    const url = URL.createObjectURL(blob);\n    const link = document.createElement('a');\n    link.href = url;\n    link.download = `cut-${cutId}-${cut?.name.replace(/\\s+/g, '-').toLowerCase()}-${new Date().toISOString().split('T')[0]}.csv`;\n    link.click();\n    URL.revokeObjectURL(url);\n  };\n\n  const handlePrint = () => {\n    window.print();\n  };\n\n  const stats = calculateStats();\n\n  const columns = [\n    { title: 'Address', dataIndex: 'address', key: 'address' },\n    {\n      title: 'Latitude',\n      dataIndex: 'latitude',\n      key: 'latitude',\n      render: (val: number) => val?.toFixed(6) || 'N/A'\n    },\n    {\n      title: 'Longitude',\n      dataIndex: 'longitude',\n      key: 'longitude',\n      render: (val: number) => val?.toFixed(6) || 'N/A'\n    },\n    { title: 'Postal Code', dataIndex: 'postalCode', key: 'postalCode' },\n    {\n      title: 'Units',\n      key: 'units',\n      render: (_: any, record: Location) => record.addresses.length\n    },\n    {\n      title: 'Residential',\n      dataIndex: 'buildingUse',\n      key: 'residential',\n      render: (val: number) => val === 1 ? 'Yes' : 'No'\n    }\n  ];\n\n  return (\n    <div className=\"cut-export-page\">\n      <div className=\"no-print\" style={{ marginBottom: 24 }}>\n        <Space>\n          <Button icon={<PrinterOutlined />} onClick={handlePrint}>\n            Print\n          </Button>\n          <Button icon={<DownloadOutlined />} onClick={exportToCSV}>\n            Export CSV\n          </Button>\n        </Space>\n      </div>\n\n      <div className=\"cut-export-header\">\n        <h1>Cut Export Report</h1>\n        <h2>{cut?.name}</h2>\n        <p>Generated: {new Date().toLocaleString()}</p>\n      </div>\n\n      <Card title=\"Statistics\" style={{ marginBottom: 24 }}>\n        <Row gutter={16}>\n          <Col span={6}>\n            <Statistic title=\"Total Locations\" value={stats.totalLocations} />\n          </Col>\n          <Col span={6}>\n            <Statistic title=\"Total Units\" value={stats.totalUnits} />\n          </Col>\n          <Col span={6}>\n            <Statistic\n              title=\"Geocoded\"\n              value={stats.geocoded}\n              suffix={`(${stats.geocodedPercent.toFixed(1)}%)`}\n            />\n          </Col>\n          <Col span={6}>\n            <Statistic\n              title=\"Residential\"\n              value={stats.residential}\n              suffix={`(${stats.residentialPercent.toFixed(1)}%)`}\n            />\n          </Col>\n        </Row>\n      </Card>\n\n      <Table\n        dataSource={locations}\n        columns={columns}\n        rowKey=\"id\"\n        loading={loading}\n        pagination={{ pageSize: 50 }}\n      />\n\n      <style>{`\n        @media print {\n          .no-print { display: none !important; }\n          @page { size: A4 landscape; margin: 0.5in; }\n          body { font-size: 10pt; }\n        }\n      `}</style>\n    </div>\n  );\n};\n\nexport default CutExportPage;\n
"},{"location":"v2/features/map/walk-sheets/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/walk-sheets/#problem-qr-codes-not-generating","title":"Problem: QR codes not generating","text":"

Symptoms: - Empty QR code section on walk sheet - Console errors about /api/qr/generate - Network 404 or 500 errors

Solutions:

  1. Verify endpoint accessibility:

    curl -X POST http://localhost:4000/api/qr/generate \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"url\":\"https://example.com\",\"size\":200}'\n

  2. Check qrcode package installed:

    cd api\nnpm list qrcode\n# If not installed:\nnpm install qrcode\nnpm install --save-dev @types/qrcode\n

  3. Verify route registration in server.ts:

    import qrRoutes from './modules/qr/qr.routes';\napp.use('/api/qr', qrRoutes);\n

  4. Check URL validation:

    // URL must start with http:// or https://\nconst validUrls = [\n  'https://example.com',  // \u2713 Valid\n  'http://example.com',   // \u2713 Valid\n  'example.com',          // \u2717 Invalid (missing protocol)\n  'ftp://example.com'     // \u2717 Invalid (wrong protocol)\n];\n

  5. Test with simple URL:

    // Test with minimal payload\nconst testQR = async () => {\n  const { data } = await api.post('/qr/generate', {\n    url: 'https://google.com'\n    // size omitted (uses default 200)\n  });\n  console.log('QR generated:', data.png.substring(0, 50));\n};\n

"},{"location":"v2/features/map/walk-sheets/#problem-print-layout-broken","title":"Problem: Print layout broken","text":"

Symptoms: - Elements overlap when printing - Missing borders or backgrounds - Incorrect page breaks - Cut-off content

Solutions:

  1. Enable background graphics in browser:
  2. Chrome: Print \u2192 More settings \u2192 Background graphics (checked)
  3. Firefox: Print \u2192 Options \u2192 Print backgrounds (checked)
  4. Safari: Print \u2192 Show Details \u2192 Print backgrounds (checked)

  5. Test print preview first:

    // Add print preview button for debugging\nconst handlePrintPreview = () => {\n  const printWindow = window.open('', '_blank');\n  printWindow?.document.write(document.documentElement.outerHTML);\n  printWindow?.print();\n};\n

  6. Check @page margins:

    @media print {\n  @page {\n    size: A4 portrait;\n    margin: 0.5in; /* Adjust if content cut off */\n  }\n}\n

  7. Prevent table row breaks:

    @media print {\n  tr {\n    page-break-inside: avoid;\n    page-break-after: auto;\n  }\n\n  thead {\n    display: table-header-group; /* Repeat on each page */\n  }\n}\n

  8. Test in different browsers:

  9. Chrome/Edge: Best print CSS support
  10. Firefox: Good, but some layout differences
  11. Safari: May require webkit prefixes

  12. Adjust font sizes if content overflows:

    @media print {\n  body { font-size: 9pt; } /* Reduce from 10pt */\n  th, td { font-size: 8pt; } /* Reduce from 9pt */\n}\n

"},{"location":"v2/features/map/walk-sheets/#problem-walk-sheet-showing-wrong-cut","title":"Problem: Walk sheet showing wrong cut","text":"

Symptoms: - Selected cut shows different locations - Location count doesn't match cut - Locations outside cut boundary visible

Solutions:

  1. Verify cutId in API request:

    console.log('Fetching locations for cut:', selectedCutId);\nconst { data } = await api.get(`/locations?cutId=${selectedCutId}`);\nconsole.log('Received locations:', data.data.length);\n

  2. Check point-in-polygon filter:

    // In locations.service.ts\nconst locations = await prisma.location.findMany({\n  where: {\n    AND: [\n      { latitude: { not: null } },\n      { longitude: { not: null } }\n    ]\n  }\n});\n\n// Filter by cut boundary\nconst cut = await prisma.cut.findUnique({ where: { id: cutId } });\nconst filtered = locations.filter(loc =>\n  isPointInPolygon([loc.longitude!, loc.latitude!], cut.geojson)\n);\n\nconsole.log('Total locations:', locations.length);\nconsole.log('Within cut:', filtered.length);\n

  3. Test with simple rectangular cut:

    {\n  \"type\": \"Polygon\",\n  \"coordinates\": [\n    [\n      [-79.40, 43.64],\n      [-79.36, 43.64],\n      [-79.36, 43.66],\n      [-79.40, 43.66],\n      [-79.40, 43.64]\n    ]\n  ]\n}\n

  4. Verify GeoJSON coordinate order:

    // Correct: [longitude, latitude]\nconst point = [loc.longitude, loc.latitude]; // \u2713\n\n// Incorrect: [latitude, longitude]\nconst point = [loc.latitude, loc.longitude]; // \u2717\n

  5. Check cut geojson validity:

  6. First and last coordinates must be identical (closed polygon)
  7. Coordinates must be [lng, lat] order
  8. Use http://geojson.io to visualize
"},{"location":"v2/features/map/walk-sheets/#problem-large-cuts-slow-to-load","title":"Problem: Large cuts slow to load","text":"

Symptoms: - Walk sheet takes > 10 seconds to load - Browser freezes during render - Print preview crashes

Solutions:

  1. Implement pagination:

    const LOCATIONS_PER_PAGE = 50;\n\nconst [currentPage, setCurrentPage] = useState(1);\nconst paginatedLocations = locations.slice(\n  (currentPage - 1) * LOCATIONS_PER_PAGE,\n  currentPage * LOCATIONS_PER_PAGE\n);\n

  2. Add location count warning:

    {locations.length > 200 && (\n  <Alert\n    message=\"Large Cut\"\n    description={`This cut has ${locations.length} locations. Consider splitting into smaller sheets.`}\n    type=\"warning\"\n    showIcon\n  />\n)}\n

  3. Use virtual scrolling for preview:

    import { List } from 'react-virtualized';\n\n// Render only visible rows during preview\n<List\n  height={600}\n  rowCount={locations.length}\n  rowHeight={40}\n  rowRenderer={({ index, style }) => (\n    <div style={style}>{renderLocationRow(locations[index])}</div>\n  )}\n/>\n

  4. Optimize QR code generation:

    // Generate QR codes only when print button clicked\nconst [qrCodesGenerated, setQrCodesGenerated] = useState(false);\n\nconst handlePrint = async () => {\n  if (!qrCodesGenerated) {\n    await generateQRCodes();\n    setQrCodesGenerated(true);\n  }\n  window.print();\n};\n

  5. Split large cuts into multiple sheets:

    // Group by postal code prefix\nconst groupedByPostal = locations.reduce((acc, loc) => {\n  const prefix = loc.postalCode?.substring(0, 3) || 'Unknown';\n  if (!acc[prefix]) acc[prefix] = [];\n  acc[prefix].push(loc);\n  return acc;\n}, {} as Record<string, Location[]>);\n\n// Generate separate sheet per group\nObject.entries(groupedByPostal).forEach(([prefix, locs]) => {\n  console.log(`${prefix}: ${locs.length} locations`);\n});\n

"},{"location":"v2/features/map/walk-sheets/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/walk-sheets/#client-side-rendering","title":"Client-Side Rendering","text":"

Walk Sheet Page Load: - Initial load: ~500ms (fetch cuts + settings) - Cut selection: ~1-2 seconds (fetch locations + generate QR codes) - Large cuts (500+ locations): ~3-5 seconds - QR code generation: ~100ms per code (parallel)

Optimization Strategies:

  1. Lazy load QR codes:

    // Only generate when visible\nconst [qrCodesVisible, setQrCodesVisible] = useState(false);\n\nuseEffect(() => {\n  const observer = new IntersectionObserver(entries => {\n    if (entries[0].isIntersecting && !qrCodesVisible) {\n      generateQRCodes();\n      setQrCodesVisible(true);\n    }\n  });\n  observer.observe(qrSectionRef.current);\n  return () => observer.disconnect();\n}, []);\n

  2. Cache QR codes in localStorage:

    const getCachedQR = (url: string): string | null => {\n  const cached = localStorage.getItem(`qr:${url}`);\n  if (cached) {\n    const { png, timestamp } = JSON.parse(cached);\n    // Cache valid for 24 hours\n    if (Date.now() - timestamp < 24 * 60 * 60 * 1000) {\n      return png;\n    }\n  }\n  return null;\n};\n\nconst cacheQR = (url: string, png: string) => {\n  localStorage.setItem(`qr:${url}`, JSON.stringify({\n    png,\n    timestamp: Date.now()\n  }));\n};\n

  3. Debounce cut selection:

    import { debounce } from 'lodash';\n\nconst debouncedFetchLocations = debounce((cutId: number) => {\n  fetchLocations(cutId);\n}, 300);\n\n<Select onChange={debouncedFetchLocations} />\n

"},{"location":"v2/features/map/walk-sheets/#server-side-performance","title":"Server-Side Performance","text":"

API Response Times: - GET /api/map-settings: ~50ms (singleton query) - GET /api/cuts/ ~100ms (single record + geojson) - GET /api/locations?cutId=X: ~500ms-2s (depends on cut size) - POST /api/qr/generate: ~50ms (QRCode.toDataURL is fast)

Database Optimization:

-- Index for cut location queries\nCREATE INDEX idx_locations_coords ON \"Location\"(latitude, longitude);\n\n-- Index for address sorting\nCREATE INDEX idx_locations_address ON \"Location\"(address);\n\n-- Composite index for geocoded locations\nCREATE INDEX idx_locations_geocoded ON \"Location\"(latitude, longitude)\n  WHERE latitude IS NOT NULL AND longitude IS NOT NULL;\n

Query Optimization:

// Use select to limit fields\nconst locations = await prisma.location.findMany({\n  where: { /* filters */ },\n  select: {\n    id: true,\n    address: true,\n    latitude: true,\n    longitude: true,\n    postalCode: true,\n    addresses: {\n      select: {\n        id: true,\n        unitNumber: true,\n        firstName: true,\n        lastName: true,\n        supportLevel: true,\n        notes: true\n      },\n      orderBy: { unitNumber: 'asc' }\n    }\n  }\n});\n
"},{"location":"v2/features/map/walk-sheets/#print-performance","title":"Print Performance","text":"

Print Dialog Load Time: - Small walk sheets (<50 locations): Instant - Medium (50-200 locations): 1-2 seconds - Large (200-500 locations): 3-5 seconds - Very large (500+ locations): Consider pagination

Browser Print Limits: - Chrome: ~1000 table rows before slowdown - Firefox: ~800 table rows - Safari: ~600 table rows

Optimization: - Use page-break-inside: avoid sparingly - Minimize complex CSS in print rules - Avoid large images (QR codes already optimized at 150px) - Split very large cuts into multiple PDFs

"},{"location":"v2/features/map/walk-sheets/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/map/walk-sheets/#backend-documentation","title":"Backend Documentation","text":""},{"location":"v2/features/map/walk-sheets/#frontend-documentation","title":"Frontend Documentation","text":""},{"location":"v2/features/map/walk-sheets/#database-documentation","title":"Database Documentation","text":""},{"location":"v2/features/map/walk-sheets/#external-resources","title":"External Resources","text":""},{"location":"v2/features/media/","title":"Media Manager","text":"

The Media Manager provides a complete video library system with upload, metadata extraction, public gallery, reaction system, and job queue monitoring. Built as a separate Fastify microservice with Drizzle ORM.

"},{"location":"v2/features/media/#overview","title":"Overview","text":"

The Media Manager consists of four integrated components:

  1. Video Library - Admin video management
  2. Upload System - Video upload with metadata extraction
  3. Public Gallery - Public video sharing
  4. Job Queue - Background job monitoring
"},{"location":"v2/features/media/#features","title":"Features","text":""},{"location":"v2/features/media/#video-library","title":"Video Library","text":""},{"location":"v2/features/media/#upload-system","title":"Upload System","text":""},{"location":"v2/features/media/#public-gallery","title":"Public Gallery","text":""},{"location":"v2/features/media/#reaction-system","title":"Reaction System","text":"

Six emoji reactions:

"},{"location":"v2/features/media/#architecture","title":"Architecture","text":""},{"location":"v2/features/media/#dual-api-design","title":"Dual API Design","text":"

Express API (Port 4000) - Main V2 features - Prisma ORM - PostgreSQL

Fastify Media API (Port 4100) - Media-specific operations - Drizzle ORM - Same PostgreSQL database - Optimized for file uploads

"},{"location":"v2/features/media/#backend-components","title":"Backend Components","text":"

Media API: - api/src/media-server.ts - Fastify entry point - api/src/modules/media/routes/ - Video, upload, shared, reactions, jobs - api/src/modules/media/services/ - FFprobe, video service - api/src/modules/media/db/schema.ts - Drizzle schema

Database Tables: - videos - Video metadata (Drizzle) - shared_media - Public gallery (Drizzle) - media_reactions - Reaction tracking (Drizzle) - media_jobs - Job queue (Drizzle)

"},{"location":"v2/features/media/#frontend-components","title":"Frontend Components","text":"

Admin Pages: - admin/src/pages/media/LibraryPage.tsx - Video library management - admin/src/pages/media/SharedMediaPage.tsx - Public gallery admin - admin/src/pages/media/MediaJobsPage.tsx - Job queue monitoring

Public Pages: - admin/src/pages/public/MediaGalleryPage.tsx - Public video gallery - admin/src/pages/public/MediaViewerPage.tsx - Video detail page

Components: - admin/src/components/media/VideoCard.tsx - Video display card - admin/src/components/media/BulkActions.tsx - Batch operations - admin/src/components/media/UploadVideoModal.tsx - Upload interface

API Clients: - admin/src/lib/media-api.ts - Authenticated media API client - admin/src/lib/media-public-api.ts - Public media API client

"},{"location":"v2/features/media/#configuration","title":"Configuration","text":""},{"location":"v2/features/media/#environment-variables","title":"Environment Variables","text":"
# Enable media features\nENABLE_MEDIA_FEATURES=true\n\n# Media API port\nMEDIA_API_PORT=4100\n\n# Media storage\nMEDIA_LIBRARY_PATH=/media/local/library    # Read-only library\nMEDIA_INBOX_PATH=/media/local/inbox        # Read-write inbox\n
"},{"location":"v2/features/media/#docker-volumes","title":"Docker Volumes","text":"
volumes:\n  # Library (read-only)\n  - /path/to/library:/media/local/library:ro\n\n  # Inbox (read-write for uploads)\n  - /path/to/inbox:/media/local/inbox:rw\n
"},{"location":"v2/features/media/#upload-system_1","title":"Upload System","text":""},{"location":"v2/features/media/#upload-flow","title":"Upload Flow","text":"
  1. Select Files
  2. Drag-and-drop or file picker
  3. Multiple file selection
  4. File type validation

  5. Upload to Inbox

  6. Stream to /media/local/inbox
  7. UUID filename (prevents conflicts)
  8. Progress tracking

  9. Extract Metadata

  10. FFprobe analysis (30s timeout)
  11. Duration, dimensions, orientation
  12. Quality calculation
  13. Audio detection

  14. Create Database Record

  15. Store metadata
  16. Set initial status
  17. Link to user

  18. Process Video (Future)

  19. Generate thumbnail
  20. Transcode formats
  21. Move to library
"},{"location":"v2/features/media/#ffprobe-metadata-extraction","title":"FFprobe Metadata Extraction","text":"

Automatically extracts:

interface VideoMetadata {\n  duration: number;        // Seconds (e.g., 125.5)\n  width: number;           // Pixels (e.g., 1920)\n  height: number;          // Pixels (e.g., 1080)\n  orientation: string;     // 'landscape' | 'portrait' | 'square'\n  quality: string;         // '4K' | '1080p' | '720p' | 'SD'\n  hasAudio: boolean;       // Audio stream detected\n}\n

Quality Calculation: - 4K: \u22652160p (3840x2160) - 1080p: \u22651080p (1920x1080) - 720p: \u2265720p (1280x720) - SD: <720p

Orientation: - Landscape: width > height - Portrait: height > width - Square: width \u2248 height (within 10%)

"},{"location":"v2/features/media/#supported-formats","title":"Supported Formats","text":""},{"location":"v2/features/media/#public-gallery_1","title":"Public Gallery","text":""},{"location":"v2/features/media/#sharing-system","title":"Sharing System","text":"

Videos can be shared publicly:

  1. Lock/Unlock - Control public visibility
  2. Category Assignment - Organize by category
  3. Public Access - View at /media
  4. Reactions - Emoji reactions from visitors
"},{"location":"v2/features/media/#categories","title":"Categories","text":"

Predefined categories:

"},{"location":"v2/features/media/#reaction-system_1","title":"Reaction System","text":""},{"location":"v2/features/media/#reaction-tracking","title":"Reaction Tracking","text":"
interface MediaReaction {\n  id: number;\n  videoId: number;\n  reactionType: string;  // 'like' | 'love' | 'laugh' | 'wow' | 'sad' | 'angry'\n  sessionId: string;      // Unique session identifier\n  createdAt: Date;\n}\n
"},{"location":"v2/features/media/#session-based-reactions","title":"Session-Based Reactions","text":""},{"location":"v2/features/media/#job-queue","title":"Job Queue","text":"

Background jobs for:

"},{"location":"v2/features/media/#job-monitoring","title":"Job Monitoring","text":"

Admin can:

"},{"location":"v2/features/media/#database-schema-drizzle","title":"Database Schema (Drizzle)","text":""},{"location":"v2/features/media/#videos-table","title":"Videos Table","text":"
export const videos = pgTable('videos', {\n  id: serial('id').primaryKey(),\n  title: varchar('title', { length: 255 }).notNull(),\n  description: text('description'),\n  filename: varchar('filename', { length: 255 }).notNull(),\n  filepath: varchar('filepath', { length: 500 }).notNull(),\n  duration: integer('duration'),        // Seconds\n  width: integer('width'),              // Pixels\n  height: integer('height'),            // Pixels\n  orientation: varchar('orientation', { length: 20 }), // 'landscape' | 'portrait' | 'square'\n  quality: varchar('quality', { length: 20 }),         // '4K' | '1080p' | '720p' | 'SD'\n  hasAudio: boolean('has_audio'),\n  tags: json('tags').$type<string[]>(),\n  locked: boolean('locked').default(false),\n  uploadedBy: integer('uploaded_by'),\n  createdAt: timestamp('created_at').defaultNow(),\n  updatedAt: timestamp('updated_at').defaultNow(),\n});\n
"},{"location":"v2/features/media/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/media/#admin-endpoints-media-api-port-4100","title":"Admin Endpoints (Media API - Port 4100)","text":"
GET    /media-api/videos               # List videos\nPOST   /media-api/videos               # Create video (manual)\nGET    /media-api/videos/:id           # Get video\nPATCH  /media-api/videos/:id           # Update video\nDELETE /media-api/videos/:id           # Delete video\nPOST   /media-api/upload               # Upload single video\nPOST   /media-api/upload/batch         # Upload multiple videos\nGET    /media-api/shared               # List shared videos\nPOST   /media-api/shared               # Share video\nDELETE /media-api/shared/:id           # Unshare video\nGET    /media-api/jobs                 # List jobs\n
"},{"location":"v2/features/media/#public-endpoints","title":"Public Endpoints","text":"
GET    /media-api/public/videos        # List public videos\nGET    /media-api/public/videos/:id    # Get public video\nPOST   /media-api/reactions            # Add/update reaction\nDELETE /media-api/reactions/:id        # Remove reaction\n
"},{"location":"v2/features/media/#security","title":"Security","text":""},{"location":"v2/features/media/#file-validation","title":"File Validation","text":""},{"location":"v2/features/media/#access-control","title":"Access Control","text":""},{"location":"v2/features/media/#performance","title":"Performance","text":""},{"location":"v2/features/media/#upload-optimization","title":"Upload Optimization","text":""},{"location":"v2/features/media/#gallery-optimization","title":"Gallery Optimization","text":""},{"location":"v2/features/media/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/media/jobs/","title":"Media Job Queue System","text":""},{"location":"v2/features/media/jobs/#overview","title":"Overview","text":"

The Media Job Queue System provides asynchronous background processing for CPU and GPU-intensive video operations. Built on a custom job queue with resource-aware scheduling, it handles everything from directory scanning to AI-powered video analysis while maintaining system stability through resource category management.

Key Features:

Access Control:

Technology Stack:

"},{"location":"v2/features/media/jobs/#architecture","title":"Architecture","text":"
flowchart TB\n    subgraph \"Job Creation\"\n        A1[Admin Action]\n        A2[Automated Trigger]\n        A3[Scheduled Task]\n    end\n\n    subgraph \"Job Queue (PostgreSQL)\"\n        Q1[Pending Jobs]\n        Q2[Queued Jobs]\n        Q3[Running Jobs]\n        Q4[Completed/Failed Jobs]\n    end\n\n    subgraph \"Worker Process\"\n        W1[Job Poller<br/>Every 5s]\n        W2[Resource Checker]\n        W3[Job Executor]\n        W4[Progress Updater]\n    end\n\n    subgraph \"Processors\"\n        P1[CPU Jobs<br/>scan, validate]\n        P2[GPU Encode<br/>reencode, compile]\n        P3[GPU AI<br/>digest, tag, scene]\n    end\n\n    subgraph \"Results\"\n        R1[Video Records Updated]\n        R2[New Files Created]\n        R3[Logs Written]\n    end\n\n    A1 --> Q1\n    A2 --> Q1\n    A3 --> Q1\n\n    Q1 --> W1\n    W1 --> W2\n    W2 -->|Check Resources| Q2\n    Q2 --> W3\n\n    W3 --> P1\n    W3 --> P2\n    W3 --> P3\n\n    W3 --> W4\n    W4 --> Q3\n\n    P1 --> R1\n    P2 --> R2\n    P3 --> R3\n\n    Q3 --> Q4\n\n    style Q1 fill:#f9f\n    style Q3 fill:#ff9\n    style Q4 fill:#9f9

Workflow:

  1. Job Creation \u2014 Admin clicks \"Re-encode\" button, API creates job record
  2. Queue Polling \u2014 Worker checks for pending jobs every 5 seconds
  3. Resource Check \u2014 Worker verifies sufficient VRAM/CPU available
  4. Job Execution \u2014 Worker runs appropriate processor (FFmpeg, AI script, etc.)
  5. Progress Updates \u2014 Worker updates job progress every ~5% completion
  6. Completion \u2014 Worker marks job complete and logs results
  7. Retry on Failure \u2014 Failed jobs can be retried with exponential backoff
"},{"location":"v2/features/media/jobs/#database-model","title":"Database Model","text":""},{"location":"v2/features/media/jobs/#jobs-table-schema","title":"Jobs Table Schema","text":"
// api/src/modules/media/db/schema.ts\nexport const jobs = pgTable('jobs', {\n  id: uuid('id').primaryKey().defaultRandom(),\n\n  // Job Definition\n  type: text('type').notNull(), // JobType enum: compilation, scan, reencode, etc.\n  status: text('status').notNull().default('pending'), // JobStatus enum\n  params: jsonb('params').$type<Record<string, any>>().notNull(), // Job-specific parameters\n\n  // Progress Tracking\n  progress: integer('progress').default(0), // 0-100\n  log: text('log').default(''), // Execution log (append-only)\n\n  // Scheduling\n  priority: integer('priority').default(5), // 1 (highest) - 10 (lowest)\n  queuePosition: integer('queue_position'), // Position in queue\n  waitingReason: text('waiting_reason'), // Why job is waiting (e.g., \"Insufficient VRAM\")\n\n  // Resource Management\n  resourceCategory: text('resource_category').notNull(), // cpu|gpu_encode|gpu_ai\n  vramRequired: integer('vram_required').default(0), // MB of VRAM needed\n\n  // Timing\n  createdAt: timestamp('created_at').defaultNow(),\n  startedAt: timestamp('started_at'),\n  completedAt: timestamp('completed_at'),\n\n  // Retry Logic\n  retryCount: integer('retry_count').default(0),\n  maxRetries: integer('max_retries').default(3),\n  retryAfter: timestamp('retry_after'), // Don't retry before this time\n});\n
"},{"location":"v2/features/media/jobs/#job-types-enum","title":"Job Types Enum","text":"Type Resource Category VRAM (MB) Description scan cpu 0 Scan directory for new videos public_scan cpu 0 Scan public gallery directory validate cpu 0 Validate video metadata (FFprobe) reencode_streaming gpu_encode 4000 Re-encode for web playback (H.264) compile_random gpu_encode 2000 Random video compilation compile_quad gpu_encode 4000 4-up grid compilation compile_mega gpu_encode 6000 Large multi-video compilation compile_gif cpu 0 Create GIF from video digest_generate gpu_ai 8000 AI-powered video digest clip_generate gpu_ai 6000 Extract clips from digest highlight_generate gpu_ai 8000 Create highlight reel tag_generation gpu_ai 6000 AI auto-tagging scene_extract gpu_ai 8000 Scene detection and extraction thumbnail_generate cpu 0 Generate thumbnail from video move_to_library cpu 0 Move video from inbox to target directory"},{"location":"v2/features/media/jobs/#job-status-enum","title":"Job Status Enum","text":"Status Description Final State pending Waiting to be picked up by worker No queued Selected by worker, waiting for resources No running Currently executing No completed Finished successfully Yes failed Execution failed (see log for details) Yes cancelled Manually cancelled by admin Yes paused Temporarily paused (can be resumed) No"},{"location":"v2/features/media/jobs/#resource-categories","title":"Resource Categories","text":"Category Typical VRAM Concurrent Limit Use Cases cpu 0 MB 5 Scanning, validation, simple encodes, GIF creation gpu_encode 2-6 GB 2 Video re-encoding, compilation, format conversion gpu_ai 6-12 GB 1 AI tagging, scene detection, digest generation, highlight extraction

VRAM Management:

Worker tracks total VRAM usage across running jobs:

const runningJobs = await db.select().from(jobs).where(eq(jobs.status, 'running'));\nconst totalVramUsed = runningJobs.reduce((sum, job) => sum + (job.vramRequired || 0), 0);\n\n// Only start new job if VRAM available\nconst TOTAL_VRAM = 16000; // 16GB GPU\nif (totalVramUsed + newJob.vramRequired <= TOTAL_VRAM) {\n  startJob(newJob);\n}\n
"},{"location":"v2/features/media/jobs/#api-endpoints","title":"API Endpoints","text":"

All endpoints require SUPER_ADMIN role.

"},{"location":"v2/features/media/jobs/#list-jobs","title":"List Jobs","text":"
GET /api/media/jobs\n

Query Parameters:

Parameter Type Default Description page number 1 Page number limit number 20 Results per page status string - Filter by status (pending, running, completed, failed) type string - Filter by job type resourceCategory string - Filter by resource category

Response:

{\n  \"data\": [\n    {\n      \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n      \"type\": \"reencode_streaming\",\n      \"status\": \"running\",\n      \"progress\": 45,\n      \"resourceCategory\": \"gpu_encode\",\n      \"vramRequired\": 4000,\n      \"priority\": 5,\n      \"params\": {\n        \"videoId\": \"660e8400-e29b-41d4-a716-446655440001\",\n        \"targetBitrate\": 2000\n      },\n      \"startedAt\": \"2026-02-13T10:30:00Z\",\n      \"createdAt\": \"2026-02-13T10:25:00Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 156,\n    \"totalPages\": 8\n  }\n}\n
"},{"location":"v2/features/media/jobs/#get-job-details","title":"Get Job Details","text":"
GET /api/media/jobs/:id\n

Response:

{\n  \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"type\": \"reencode_streaming\",\n  \"status\": \"completed\",\n  \"progress\": 100,\n  \"log\": \"Starting re-encode...\\nFFmpeg command: ffmpeg -i input.mp4 -c:v h264 -preset medium -crf 23 output.mp4\\nProgress: 25%\\nProgress: 50%\\nProgress: 75%\\nProgress: 100%\\nCompleted successfully\",\n  \"params\": {\n    \"videoId\": \"660e8400-e29b-41d4-a716-446655440001\",\n    \"inputPath\": \"inbox/original.mp4\",\n    \"outputPath\": \"playback/encoded.mp4\",\n    \"targetBitrate\": 2000\n  },\n  \"resourceCategory\": \"gpu_encode\",\n  \"vramRequired\": 4000,\n  \"priority\": 5,\n  \"retryCount\": 0,\n  \"maxRetries\": 3,\n  \"createdAt\": \"2026-02-13T10:25:00Z\",\n  \"startedAt\": \"2026-02-13T10:30:00Z\",\n  \"completedAt\": \"2026-02-13T10:45:00Z\"\n}\n
"},{"location":"v2/features/media/jobs/#create-job","title":"Create Job","text":"
POST /api/media/jobs\n

Request Body:

{\n  \"type\": \"reencode_streaming\",\n  \"params\": {\n    \"videoId\": \"660e8400-e29b-41d4-a716-446655440001\",\n    \"targetBitrate\": 2000\n  },\n  \"priority\": 5,\n  \"resourceCategory\": \"gpu_encode\",\n  \"vramRequired\": 4000\n}\n

Response:

{\n  \"id\": \"770e8400-e29b-41d4-a716-446655440002\",\n  \"type\": \"reencode_streaming\",\n  \"status\": \"pending\",\n  \"progress\": 0,\n  \"createdAt\": \"2026-02-13T11:00:00Z\"\n}\n
"},{"location":"v2/features/media/jobs/#retry-failed-job","title":"Retry Failed Job","text":"
POST /api/media/jobs/:id/retry\n

Response:

{\n  \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"status\": \"pending\",\n  \"retryCount\": 1,\n  \"retryAfter\": null,\n  \"log\": \"Starting re-encode...\\n[Previous logs...]\\n--- RETRY ATTEMPT 1 ---\\n\"\n}\n

Retry Logic:

"},{"location":"v2/features/media/jobs/#cancel-job","title":"Cancel Job","text":"
POST /api/media/jobs/:id/cancel\n

Response:

{\n  \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"status\": \"cancelled\",\n  \"log\": \"Starting re-encode...\\nProgress: 25%\\n--- JOB CANCELLED BY ADMIN ---\"\n}\n

Notes:

"},{"location":"v2/features/media/jobs/#pauseresume-job","title":"Pause/Resume Job","text":"
POST /api/media/jobs/:id/pause\nPOST /api/media/jobs/:id/resume\n

Pause Response:

{\n  \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"status\": \"paused\"\n}\n

Resume Response:

{\n  \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"status\": \"pending\"\n}\n
"},{"location":"v2/features/media/jobs/#queue-statistics","title":"Queue Statistics","text":"
GET /api/media/jobs/stats\n

Response:

{\n  \"pending\": 12,\n  \"queued\": 2,\n  \"running\": 3,\n  \"completed\": 1458,\n  \"failed\": 23,\n  \"paused\": 1,\n  \"totalVramUsed\": 12000,\n  \"totalVramAvailable\": 16000,\n  \"averageProcessingTime\": 245,\n  \"jobsByType\": {\n    \"reencode_streaming\": 45,\n    \"scan\": 8,\n    \"compile_random\": 12\n  }\n}\n
"},{"location":"v2/features/media/jobs/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/media/jobs/#viewing-job-queue","title":"Viewing Job Queue","text":"
  1. Navigate to Media \u2192 Jobs in admin sidebar
  2. Table displays all jobs with:
  3. Job type icon
  4. Status badge (color-coded)
  5. Progress bar
  6. Priority indicator
  7. Resource category
  8. Created/started/completed times
  9. Use filters at top:
  10. Status dropdown (All / Pending / Running / Completed / Failed)
  11. Type dropdown (job type)
  12. Resource dropdown (CPU / GPU Encode / GPU AI)
"},{"location":"v2/features/media/jobs/#creating-jobs-manually","title":"Creating Jobs Manually","text":"

Option 1: From Library Page

  1. Select video in library table
  2. Click \"Actions\" dropdown
  3. Select action:
  4. \"Re-encode for Streaming\"
  5. \"Generate Thumbnail\"
  6. \"Validate Metadata\"
  7. \"Move to Directory\"
  8. Confirm job creation
  9. Redirected to Jobs page showing new job

Option 2: From Jobs Page

  1. Click \"Create Job\" button
  2. Modal opens with form:
  3. Type dropdown (15+ job types)
  4. Video selector (search by title/filename)
  5. Priority slider (1-10)
  6. Parameters JSON editor (advanced)
  7. Click \"Create\"
  8. Job appears in pending queue
"},{"location":"v2/features/media/jobs/#monitoring-job-progress","title":"Monitoring Job Progress","text":"

Real-Time Updates:

  1. Jobs page polls API every 2 seconds for running jobs
  2. Progress bars update smoothly (0-100%)
  3. Status badges change color:
  4. Grey: Pending
  5. Blue: Queued
  6. Yellow: Running
  7. Green: Completed
  8. Red: Failed

Detailed Logs:

  1. Click job row to expand details panel
  2. View execution log in monospace text area
  3. Log updates in real-time while job running
  4. Example log output:
[2026-02-13 10:30:15] Starting re-encode job\n[2026-02-13 10:30:16] Input: /media/local/inbox/original.mp4\n[2026-02-13 10:30:16] Output: /media/local/playback/encoded.mp4\n[2026-02-13 10:30:17] FFmpeg command: ffmpeg -i /media/local/inbox/original.mp4 -c:v libx264 -preset medium -crf 23 -c:a aac -b:a 128k /media/local/playback/encoded.mp4\n[2026-02-13 10:30:20] Progress: 5%\n[2026-02-13 10:30:25] Progress: 15%\n[2026-02-13 10:30:30] Progress: 25%\n...\n[2026-02-13 10:45:00] Progress: 100%\n[2026-02-13 10:45:01] Re-encode completed successfully\n[2026-02-13 10:45:02] Output file size: 25.3 MB\n
"},{"location":"v2/features/media/jobs/#retrying-failed-jobs","title":"Retrying Failed Jobs","text":"
  1. Filter for Failed jobs
  2. Click job row to view error log
  3. Identify failure reason (e.g., \"FFmpeg error: codec not supported\")
  4. Fix underlying issue (install codec, fix file path, etc.)
  5. Click \"Retry\" button
  6. Job resets to pending status
  7. Worker picks up job again

Auto-Retry:

Jobs automatically retry up to 3 times with exponential backoff:

"},{"location":"v2/features/media/jobs/#cancelling-jobs","title":"Cancelling Jobs","text":"
  1. Find job in pending/queued/running state
  2. Click \"Cancel\" button
  3. Confirm cancellation dialog
  4. Job marked as cancelled
  5. If running, worker stops after current chunk completes
"},{"location":"v2/features/media/jobs/#pausingresuming-jobs","title":"Pausing/Resuming Jobs","text":"

Use Case: Temporarily stop low-priority jobs to free resources for urgent tasks

  1. Select low-priority pending job
  2. Click \"Pause\" button
  3. Job status changes to paused (greyed out)
  4. Worker skips paused jobs
  5. When ready, click \"Resume\"
  6. Job returns to pending queue
"},{"location":"v2/features/media/jobs/#job-type-details","title":"Job Type Details","text":""},{"location":"v2/features/media/jobs/#scan-jobs-scan-public_scan","title":"Scan Jobs (scan, public_scan)","text":"

Purpose: Scan filesystem directory for new videos and create database records

Parameters:

{\n  \"directoryType\": \"videos\",\n  \"skipExisting\": true\n}\n

Process:

  1. Read directory /media/local/library/{directoryType}/
  2. Filter for video extensions (.mp4, .mov, etc.)
  3. Check each file against database (by path)
  4. Create records for new files
  5. Run FFprobe on new files
  6. Update progress: files processed / total files

Typical Duration: 2-30 seconds (depends on file count)

"},{"location":"v2/features/media/jobs/#validation-jobs-validate","title":"Validation Jobs (validate)","text":"

Purpose: Re-run FFprobe to refresh video metadata

Parameters:

{\n  \"videoId\": \"660e8400-e29b-41d4-a716-446655440001\"\n}\n

Process:

  1. Fetch video record from database
  2. Build full file path
  3. Run FFprobe extraction
  4. Update database with fresh metadata
  5. Mark video as valid/invalid based on result

Typical Duration: 100-500ms per video

"},{"location":"v2/features/media/jobs/#re-encode-jobs-reencode_streaming","title":"Re-encode Jobs (reencode_streaming)","text":"

Purpose: Convert video to web-optimized format (H.264, web-friendly profile)

Parameters:

{\n  \"videoId\": \"660e8400-e29b-41d4-a716-446655440001\",\n  \"targetBitrate\": 2000,\n  \"preset\": \"medium\",\n  \"crf\": 23\n}\n

FFmpeg Command:

ffmpeg -i /media/local/inbox/original.mp4 \\\n  -c:v libx264 \\\n  -preset medium \\\n  -crf 23 \\\n  -maxrate 2000k \\\n  -bufsize 4000k \\\n  -c:a aac \\\n  -b:a 128k \\\n  -movflags +faststart \\\n  /media/local/playback/encoded.mp4\n

Process:

  1. Validate input file exists
  2. Build FFmpeg command
  3. Start encoding process
  4. Parse FFmpeg progress output
  5. Update job progress every ~5%
  6. Create new video record for encoded file
  7. Update original video reencodeJobId reference

Typical Duration: 5-30 minutes (depends on video length and resolution)

"},{"location":"v2/features/media/jobs/#compilation-jobs-compile_random-compile_quad-compile_mega","title":"Compilation Jobs (compile_random, compile_quad, compile_mega)","text":"

Purpose: Merge multiple videos into single compilation

Parameters (Random):

{\n  \"count\": 10,\n  \"minDuration\": 30,\n  \"maxDuration\": 120,\n  \"orientation\": \"landscape\",\n  \"outputPath\": \"compilations/random-001.mp4\"\n}\n

Process:

  1. Query database for videos matching criteria (orientation, duration range)
  2. Randomly select count videos
  3. Build FFmpeg concat demuxer file list
  4. Run FFmpeg compilation
  5. Create new video record for compilation
  6. Update progress based on FFmpeg output

Quad Compilation (4-up grid):

ffmpeg -i video1.mp4 -i video2.mp4 -i video3.mp4 -i video4.mp4 \\\n  -filter_complex \"[0:v][1:v][2:v][3:v]xstack=inputs=4:layout=0_0|w0_0|0_h0|w0_h0[v]\" \\\n  -map \"[v]\" \\\n  output.mp4\n

Typical Duration: 10-60 minutes

"},{"location":"v2/features/media/jobs/#digest-generation-digest_generate","title":"Digest Generation (digest_generate)","text":"

Purpose: AI-powered video digest creation (future feature)

Parameters:

{\n  \"videoId\": \"660e8400-e29b-41d4-a716-446655440001\",\n  \"targetLength\": 60,\n  \"includeHighlights\": true\n}\n

Process (Planned):

  1. Extract frames at 1 FPS
  2. Run AI scene detection
  3. Identify highlights (action, faces, motion)
  4. Select best segments totaling target length
  5. Compile segments into digest video

GPU AI Required: 8GB VRAM

"},{"location":"v2/features/media/jobs/#thumbnail-generation-thumbnail_generate","title":"Thumbnail Generation (thumbnail_generate)","text":"

Purpose: Extract thumbnail image from video

Parameters:

{\n  \"videoId\": \"660e8400-e29b-41d4-a716-446655440001\",\n  \"timestamp\": 5,\n  \"width\": 640\n}\n

FFmpeg Command:

ffmpeg -i /media/local/library/videos/sample.mp4 \\\n  -ss 00:00:05 \\\n  -vframes 1 \\\n  -vf scale=640:-1 \\\n  /media/local/thumbnails/sample.jpg\n

Process:

  1. Seek to timestamp (default: 25% into video)
  2. Extract single frame
  3. Scale to width (preserve aspect ratio)
  4. Save as JPEG
  5. Update video record with thumbnailPath

Typical Duration: 1-5 seconds

"},{"location":"v2/features/media/jobs/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/media/jobs/#create-re-encode-job","title":"Create Re-encode Job","text":"
// api/src/modules/media/routes/jobs.routes.ts\nimport { db } from '@/modules/media/db';\nimport { jobs, videos } from '@/modules/media/db/schema';\n\napp.post('/api/media/jobs/reencode', async (req, reply) => {\n  const { videoId, targetBitrate = 2000, preset = 'medium', crf = 23 } = req.body;\n\n  // Fetch video\n  const [video] = await db\n    .select()\n    .from(videos)\n    .where(eq(videos.id, videoId))\n    .limit(1);\n\n  if (!video) {\n    return reply.code(404).send({ error: 'Video not found' });\n  }\n\n  // Create job\n  const [job] = await db\n    .insert(jobs)\n    .values({\n      type: 'reencode_streaming',\n      status: 'pending',\n      params: {\n        videoId,\n        inputPath: video.path,\n        outputPath: `playback/${video.filename}`,\n        targetBitrate,\n        preset,\n        crf,\n      },\n      resourceCategory: 'gpu_encode',\n      vramRequired: 4000,\n      priority: 5,\n    })\n    .returning();\n\n  reply.send(job);\n});\n
"},{"location":"v2/features/media/jobs/#job-worker-polling-loop","title":"Job Worker (Polling Loop)","text":"
// api/src/modules/media/services/job-worker.service.ts\nimport { db } from '@/modules/media/db';\nimport { jobs } from '@/modules/media/db/schema';\nimport { eq, and, lte } from 'drizzle-orm';\n\nexport class JobWorkerService {\n  private polling = false;\n\n  async start() {\n    this.polling = true;\n    console.log('Job worker started');\n\n    while (this.polling) {\n      try {\n        await this.processNextJob();\n      } catch (error) {\n        console.error('Job worker error:', error);\n      }\n\n      // Wait 5 seconds before next poll\n      await new Promise((resolve) => setTimeout(resolve, 5000));\n    }\n  }\n\n  async stop() {\n    this.polling = false;\n    console.log('Job worker stopped');\n  }\n\n  private async processNextJob() {\n    // Find next pending job (highest priority first)\n    const [job] = await db\n      .select()\n      .from(jobs)\n      .where(eq(jobs.status, 'pending'))\n      .orderBy(jobs.priority, jobs.createdAt)\n      .limit(1);\n\n    if (!job) {\n      return; // No jobs in queue\n    }\n\n    // Check resource availability\n    const canRun = await this.checkResources(job);\n    if (!canRun) {\n      // Update waiting reason\n      await db\n        .update(jobs)\n        .set({ waitingReason: 'Insufficient resources' })\n        .where(eq(jobs.id, job.id));\n      return;\n    }\n\n    // Start job\n    await this.executeJob(job);\n  }\n\n  private async checkResources(job: any): Promise<boolean> {\n    // Get running jobs\n    const runningJobs = await db\n      .select()\n      .from(jobs)\n      .where(eq(jobs.status, 'running'));\n\n    // Calculate total VRAM used\n    const totalVramUsed = runningJobs.reduce(\n      (sum, j) => sum + (j.vramRequired || 0),\n      0\n    );\n\n    const TOTAL_VRAM = 16000; // 16GB GPU\n    const available = TOTAL_VRAM - totalVramUsed;\n\n    if (job.vramRequired && job.vramRequired > available) {\n      return false; // Not enough VRAM\n    }\n\n    // Check concurrent job limits by category\n    const categoryCount = runningJobs.filter(\n      (j) => j.resourceCategory === job.resourceCategory\n    ).length;\n\n    const limits = {\n      cpu: 5,\n      gpu_encode: 2,\n      gpu_ai: 1,\n    };\n\n    if (categoryCount >= limits[job.resourceCategory as keyof typeof limits]) {\n      return false; // Category limit reached\n    }\n\n    return true; // Resources available\n  }\n\n  private async executeJob(job: any) {\n    // Mark as running\n    await db\n      .update(jobs)\n      .set({\n        status: 'running',\n        startedAt: new Date(),\n        waitingReason: null,\n      })\n      .where(eq(jobs.id, job.id));\n\n    try {\n      // Execute job based on type\n      switch (job.type) {\n        case 'reencode_streaming':\n          await this.executeReencode(job);\n          break;\n        case 'scan':\n          await this.executeScan(job);\n          break;\n        case 'thumbnail_generate':\n          await this.executeThumbnail(job);\n          break;\n        // ... other job types\n      }\n\n      // Mark as completed\n      await db\n        .update(jobs)\n        .set({\n          status: 'completed',\n          progress: 100,\n          completedAt: new Date(),\n        })\n        .where(eq(jobs.id, job.id));\n    } catch (error: any) {\n      // Mark as failed\n      await db\n        .update(jobs)\n        .set({\n          status: 'failed',\n          log: (job.log || '') + `\\n\\n--- ERROR ---\\n${error.message}`,\n        })\n        .where(eq(jobs.id, job.id));\n\n      // Schedule retry if under max retries\n      if (job.retryCount < job.maxRetries) {\n        const retryDelay = Math.pow(2, job.retryCount) * 60 * 1000; // Exponential backoff\n        await db\n          .update(jobs)\n          .set({\n            status: 'pending',\n            retryCount: job.retryCount + 1,\n            retryAfter: new Date(Date.now() + retryDelay),\n          })\n          .where(eq(jobs.id, job.id));\n      }\n    }\n  }\n\n  private async executeReencode(job: any) {\n    const { inputPath, outputPath, targetBitrate, preset, crf } = job.params;\n\n    const inputFull = path.join(process.env.MEDIA_LIBRARY_PATH!, inputPath);\n    const outputFull = path.join(process.env.MEDIA_LIBRARY_PATH!, outputPath);\n\n    const command = `ffmpeg -i \"${inputFull}\" -c:v libx264 -preset ${preset} -crf ${crf} -maxrate ${targetBitrate}k -bufsize ${targetBitrate * 2}k -c:a aac -b:a 128k -movflags +faststart \"${outputFull}\"`;\n\n    await this.appendLog(job.id, `Starting re-encode\\nCommand: ${command}`);\n\n    // Execute FFmpeg (simplified - real implementation uses spawn for progress parsing)\n    await execAsync(command);\n\n    await this.appendLog(job.id, 'Re-encode completed successfully');\n  }\n\n  private async appendLog(jobId: string, message: string) {\n    const timestamp = new Date().toISOString();\n    const logEntry = `[${timestamp}] ${message}`;\n\n    await db\n      .update(jobs)\n      .set({\n        log: sql`${jobs.log} || E'\\n' || ${logEntry}`,\n      })\n      .where(eq(jobs.id, jobId));\n  }\n}\n\n// Start worker\nexport const jobWorker = new JobWorkerService();\njobWorker.start();\n
"},{"location":"v2/features/media/jobs/#frontend-jobs-page","title":"Frontend: Jobs Page","text":"
// admin/src/pages/media/MediaJobsPage.tsx\nimport { Table, Tag, Progress, Button, Space, Select, message } from 'antd';\nimport { useEffect, useState } from 'react';\nimport { mediaApi } from '@/lib/media-api';\n\nexport default function MediaJobsPage() {\n  const [jobs, setJobs] = useState([]);\n  const [loading, setLoading] = useState(false);\n  const [filter, setFilter] = useState({ status: undefined, type: undefined });\n  const [polling, setPolling] = useState(true);\n\n  const fetchJobs = async () => {\n    setLoading(true);\n    try {\n      const { data } = await mediaApi.get('/api/media/jobs', {\n        params: filter,\n      });\n      setJobs(data.data);\n    } catch (error) {\n      console.error('Failed to fetch jobs:', error);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  useEffect(() => {\n    fetchJobs();\n  }, [filter]);\n\n  // Poll for running jobs every 2 seconds\n  useEffect(() => {\n    if (!polling) return;\n\n    const interval = setInterval(() => {\n      const hasRunning = jobs.some((j: any) => j.status === 'running');\n      if (hasRunning) {\n        fetchJobs();\n      }\n    }, 2000);\n\n    return () => clearInterval(interval);\n  }, [polling, jobs]);\n\n  const handleRetry = async (id: string) => {\n    try {\n      await mediaApi.post(`/api/media/jobs/${id}/retry`);\n      message.success('Job queued for retry');\n      fetchJobs();\n    } catch (error) {\n      message.error('Retry failed');\n    }\n  };\n\n  const handleCancel = async (id: string) => {\n    try {\n      await mediaApi.post(`/api/media/jobs/${id}/cancel`);\n      message.success('Job cancelled');\n      fetchJobs();\n    } catch (error) {\n      message.error('Cancel failed');\n    }\n  };\n\n  const statusColors: Record<string, string> = {\n    pending: 'default',\n    queued: 'blue',\n    running: 'processing',\n    completed: 'success',\n    failed: 'error',\n    cancelled: 'default',\n    paused: 'warning',\n  };\n\n  const columns = [\n    {\n      title: 'Type',\n      dataIndex: 'type',\n      width: 150,\n      render: (type: string) => <span style={{ fontFamily: 'monospace' }}>{type}</span>,\n    },\n    {\n      title: 'Status',\n      dataIndex: 'status',\n      width: 100,\n      render: (status: string) => <Tag color={statusColors[status]}>{status.toUpperCase()}</Tag>,\n    },\n    {\n      title: 'Progress',\n      dataIndex: 'progress',\n      width: 150,\n      render: (progress: number, record: any) => (\n        record.status === 'running' ? (\n          <Progress percent={progress} size=\"small\" status=\"active\" />\n        ) : record.status === 'completed' ? (\n          <Progress percent={100} size=\"small\" status=\"success\" />\n        ) : record.status === 'failed' ? (\n          <Progress percent={progress} size=\"small\" status=\"exception\" />\n        ) : (\n          <Progress percent={progress} size=\"small\" />\n        )\n      ),\n    },\n    {\n      title: 'Resource',\n      dataIndex: 'resourceCategory',\n      width: 120,\n    },\n    {\n      title: 'Priority',\n      dataIndex: 'priority',\n      width: 80,\n      render: (priority: number) => (\n        <Tag color={priority <= 3 ? 'red' : priority <= 6 ? 'orange' : 'default'}>\n          {priority}\n        </Tag>\n      ),\n    },\n    {\n      title: 'Created',\n      dataIndex: 'createdAt',\n      width: 150,\n      render: (date: string) => new Date(date).toLocaleString(),\n    },\n    {\n      title: 'Actions',\n      width: 200,\n      render: (_: any, record: any) => (\n        <Space>\n          {record.status === 'failed' && (\n            <Button size=\"small\" onClick={() => handleRetry(record.id)}>\n              Retry\n            </Button>\n          )}\n          {['pending', 'queued', 'running'].includes(record.status) && (\n            <Button size=\"small\" danger onClick={() => handleCancel(record.id)}>\n              Cancel\n            </Button>\n          )}\n          <Button size=\"small\" onClick={() => window.open(`/app/media/jobs/${record.id}`, '_blank')}>\n            View Log\n          </Button>\n        </Space>\n      ),\n    },\n  ];\n\n  return (\n    <div>\n      <Space style={{ marginBottom: 16 }}>\n        <Select\n          placeholder=\"Filter by status\"\n          style={{ width: 150 }}\n          onChange={(value) => setFilter({ ...filter, status: value })}\n          allowClear\n        >\n          <Select.Option value=\"pending\">Pending</Select.Option>\n          <Select.Option value=\"running\">Running</Select.Option>\n          <Select.Option value=\"completed\">Completed</Select.Option>\n          <Select.Option value=\"failed\">Failed</Select.Option>\n        </Select>\n\n        <Select\n          placeholder=\"Filter by type\"\n          style={{ width: 200 }}\n          onChange={(value) => setFilter({ ...filter, type: value })}\n          allowClear\n        >\n          <Select.Option value=\"scan\">Scan</Select.Option>\n          <Select.Option value=\"reencode_streaming\">Re-encode</Select.Option>\n          <Select.Option value=\"compile_random\">Compilation</Select.Option>\n        </Select>\n\n        <Button onClick={() => setPolling(!polling)}>\n          {polling ? 'Stop Auto-Refresh' : 'Start Auto-Refresh'}\n        </Button>\n      </Space>\n\n      <Table\n        columns={columns}\n        dataSource={jobs}\n        loading={loading}\n        rowKey=\"id\"\n        pagination={{ pageSize: 20 }}\n      />\n    </div>\n  );\n}\n
"},{"location":"v2/features/media/jobs/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/media/jobs/#problem-jobs-stuck-in-pending","title":"Problem: Jobs Stuck in Pending","text":"

Symptoms:

Solutions:

  1. Check worker process running:
docker compose ps media-api\n# Should show \"Up\" status\n\ndocker compose logs media-api | grep \"Job worker\"\n# Should show \"Job worker started\"\n
  1. Manually trigger worker:
# Restart media-api container\ndocker compose restart media-api\n\n# Worker starts automatically on container boot\n
  1. Check worker logs for errors:
docker compose logs -f media-api | grep ERROR\n# Look for database connection errors, permission issues\n
  1. Verify database connection:
# Test database accessible from container\ndocker compose exec media-api psql $DATABASE_URL -c \"SELECT COUNT(*) FROM jobs WHERE status='pending';\"\n
"},{"location":"v2/features/media/jobs/#problem-job-fails-immediately","title":"Problem: Job Fails Immediately","text":"

Symptoms:

Solutions:

  1. Check job log in database:
SELECT log FROM jobs WHERE id = 'JOB_ID';\n
  1. Verify FFmpeg installed:
docker compose exec media-api which ffmpeg\n# Should output: /usr/bin/ffmpeg\n\ndocker compose exec media-api ffmpeg -version\n
  1. Check file paths valid:
# Verify input file exists\ndocker compose exec media-api ls -la /media/local/library/inbox/original.mp4\n\n# Check output directory writable\ndocker compose exec media-api touch /media/local/playback/test.txt\n
  1. Test FFmpeg command manually:
# Copy command from job log, run manually\ndocker compose exec media-api ffmpeg -i /media/local/inbox/test.mp4 -c:v libx264 /media/local/playback/test-output.mp4\n
"},{"location":"v2/features/media/jobs/#problem-re-encode-job-hangs-at-same-progress","title":"Problem: Re-encode Job Hangs at Same Progress","text":"

Symptoms:

Solutions:

  1. Check FFmpeg process still running:
docker compose exec media-api ps aux | grep ffmpeg\n# Should show ffmpeg process\n\n# If not running, worker crashed\ndocker compose logs media-api --tail 100\n
  1. Kill hung FFmpeg process:
docker compose exec media-api pkill -9 ffmpeg\n\n# Job will fail and can be retried\n
  1. Check disk space:
df -h /media/local/playback\n# If 100% full, encoding fails\n\n# Free space\ndocker compose exec media-api rm /media/local/playback/*.partial\n
  1. Increase FFmpeg timeout (if very large file):
// api/src/modules/media/services/job-worker.service.ts\nconst FFMPEG_TIMEOUT = 3600000; // 1 hour (from 30 minutes)\n
"},{"location":"v2/features/media/jobs/#problem-gpu-out-of-memory-errors","title":"Problem: GPU Out of Memory Errors","text":"

Symptoms:

Solutions:

  1. Check total VRAM available:
nvidia-smi\n# Shows GPU memory usage\n\n# Should show < 16GB used (adjust based on your GPU)\n
  1. Reduce concurrent GPU job limit:
// api/src/modules/media/services/job-worker.service.ts\nconst limits = {\n  cpu: 5,\n  gpu_encode: 1,  // Reduced from 2\n  gpu_ai: 1,\n};\n
  1. Increase VRAM requirements for jobs:
// Jobs require more VRAM than specified\n// Update job creation to use higher vramRequired values\n{\n  type: 'reencode_streaming',\n  vramRequired: 6000,  // Increased from 4000\n}\n
  1. Kill running GPU jobs:
# Stop all media jobs\ndocker compose exec media-api pkill -9 ffmpeg\n\n# Update stuck jobs to failed status\ndocker compose exec v2-postgres psql -U changemaker -d v2_changemaker \\\n  -c \"UPDATE jobs SET status='failed' WHERE status='running';\"\n
"},{"location":"v2/features/media/jobs/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/media/jobs/#job-queue-throughput","title":"Job Queue Throughput","text":"

Scaling Factors:

Bottlenecks:

  1. GPU Memory \u2014 Limits concurrent GPU jobs
  2. Disk I/O \u2014 Reading/writing large video files
  3. CPU \u2014 FFmpeg encoding uses all available cores

Optimization:

"},{"location":"v2/features/media/jobs/#database-performance","title":"Database Performance","text":"

Job Queue Index:

CREATE INDEX idx_jobs_status_priority ON jobs(status, priority, created_at);\n

Query Performance:

Optimization:

"},{"location":"v2/features/media/jobs/#monitoring-observability","title":"Monitoring & Observability","text":""},{"location":"v2/features/media/jobs/#prometheus-metrics","title":"Prometheus Metrics","text":"
// api/src/utils/metrics.ts\nimport { Counter, Gauge } from 'prom-client';\n\nexport const mediaJobsTotal = new Counter({\n  name: 'media_jobs_total',\n  help: 'Total media jobs created',\n  labelNames: ['type', 'status'],\n});\n\nexport const mediaJobsPending = new Gauge({\n  name: 'media_jobs_pending',\n  help: 'Number of pending media jobs',\n});\n\nexport const mediaJobsRunning = new Gauge({\n  name: 'media_jobs_running',\n  help: 'Number of running media jobs',\n  labelNames: ['resourceCategory'],\n});\n\nexport const mediaVramUsed = new Gauge({\n  name: 'media_vram_used_mb',\n  help: 'Total VRAM used by running jobs (MB)',\n});\n\n// Update metrics in worker\nmediaJobsPending.set(pendingCount);\nmediaJobsRunning.set({ resourceCategory: 'gpu_encode' }, gpuEncodeCount);\nmediaVramUsed.set(totalVramUsed);\n
"},{"location":"v2/features/media/jobs/#grafana-dashboard-panel","title":"Grafana Dashboard Panel","text":"

Job Queue Status:

# Pending jobs count\nmedia_jobs_pending\n\n# Running jobs by category\nsum(media_jobs_running) by (resourceCategory)\n\n# VRAM usage percentage\n(media_vram_used_mb / 16000) * 100\n

Alert Rules:

# configs/prometheus/alerts.yml\ngroups:\n  - name: media_jobs\n    rules:\n      - alert: MediaJobQueueBacklog\n        expr: media_jobs_pending > 50\n        for: 30m\n        labels:\n          severity: warning\n        annotations:\n          summary: \"Media job queue backlog\"\n          description: \"{{ $value }} jobs pending for 30+ minutes\"\n\n      - alert: MediaJobsStuckRunning\n        expr: sum(media_jobs_running) == 0 AND media_jobs_pending > 0\n        for: 10m\n        labels:\n          severity: critical\n        annotations:\n          summary: \"Media jobs stuck\"\n          description: \"Jobs pending but worker not processing\"\n
"},{"location":"v2/features/media/jobs/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/media/jobs/#backend-documentation","title":"Backend Documentation","text":""},{"location":"v2/features/media/jobs/#frontend-documentation","title":"Frontend Documentation","text":""},{"location":"v2/features/media/jobs/#feature-documentation","title":"Feature Documentation","text":""},{"location":"v2/features/media/jobs/#next-steps","title":"Next Steps","text":"

After mastering the job queue:

  1. Create Custom Jobs \u2014 Implement new job types for domain-specific processing
  2. Optimize Scheduling \u2014 Tune resource limits and priority settings for your workload
  3. Monitor Performance \u2014 Set up Grafana dashboards and alerts for job queue health
  4. Distributed Workers \u2014 Scale horizontally by running workers on multiple machines

Hands-On Practice:

# 1. Create re-encode job\ncurl -X POST http://localhost:4100/api/media/jobs \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"type\": \"reencode_streaming\",\n    \"params\": { \"videoId\": \"VIDEO_ID\", \"targetBitrate\": 2000 },\n    \"priority\": 5\n  }'\n\n# 2. Monitor job progress\nwatch -n 2 'curl -s http://localhost:4100/api/media/jobs/JOB_ID | jq \".progress\"'\n\n# 3. View job logs\ncurl http://localhost:4100/api/media/jobs/JOB_ID | jq -r \".log\"\n\n# 4. Check queue stats\ncurl http://localhost:4100/api/media/jobs/stats | jq\n

Last Updated: 2026-02-13 Version: V2.0 Maintainer: Changemaker Lite Team

"},{"location":"v2/features/media/public-gallery/","title":"Public Video Gallery","text":""},{"location":"v2/features/media/public-gallery/#overview","title":"Overview","text":"

The Public Video Gallery provides a visitor-friendly interface for browsing and watching shared videos without requiring authentication. Built with category-based organization, reaction systems, and view tracking, it transforms the admin video library into a public-facing media platform similar to YouTube or Vimeo.

Key Features:

Access Control:

Technology Stack:

"},{"location":"v2/features/media/public-gallery/#architecture","title":"Architecture","text":"
flowchart TB\n    subgraph \"Public Users\"\n        U1[Desktop Browser]\n        U2[Mobile Browser]\n        U3[Social Media Bot]\n    end\n\n    subgraph \"Admin Control\"\n        A1[Admin User]\n        A2[SharedMediaPage]\n    end\n\n    subgraph \"Public Routes (No Auth)\"\n        P1[GET /api/public/media]\n        P2[GET /api/public/media/:id]\n        P3[POST /api/public/media/:id/view]\n        P4[POST /api/public/media/:id/reaction]\n        P5[POST /api/public/media/:id/comment]\n    end\n\n    subgraph \"Admin Routes (Auth)\"\n        A3[PUT /api/media/videos/:id/share]\n        A4[PUT /api/media/videos/:id/unshare]\n    end\n\n    subgraph \"Database\"\n        D1[(videos table)]\n        D2[(reactions table)]\n        D3[(comments table)]\n        D4[(view_logs table)]\n    end\n\n    subgraph \"Cache\"\n        C1[Redis<br/>Public Videos<br/>5 min TTL]\n    end\n\n    U1 --> P1\n    U2 --> P1\n    U3 --> P1\n\n    U1 --> P2\n    U2 --> P2\n\n    U1 --> P3\n    U1 --> P4\n    U1 --> P5\n\n    A1 --> A2\n    A2 --> A3\n    A2 --> A4\n\n    P1 --> C1\n    C1 --> D1\n\n    P2 --> D1\n    P3 --> D4\n    P4 --> D2\n    P5 --> D3\n\n    A3 --> D1\n    A4 --> D1\n\n    style P1 fill:#2ecc71\n    style P2 fill:#2ecc71\n    style C1 fill:#e74c3c\n    style A2 fill:#3498db

Workflow:

  1. Admin Shares Video \u2014 Admin clicks \"Share\" button on SharedMediaPage \u2192 video marked public
  2. Public Browse \u2014 Visitor navigates to /media \u2192 sees grid of public videos
  3. Video Player \u2014 Visitor clicks video card \u2192 opens /media/:id \u2192 player page
  4. Engagement \u2014 Visitor reacts, comments, or shares video
  5. View Tracking \u2014 Frontend tracks watch time, sends to API on pause/end
  6. Related Videos \u2014 API suggests 3 similar videos (same category/creator)
"},{"location":"v2/features/media/public-gallery/#database-models","title":"Database Models","text":""},{"location":"v2/features/media/public-gallery/#videos-table-public-fields","title":"Videos Table (Public Fields)","text":"
// Only expose public-safe fields\ninterface PublicVideo {\n  id: string;\n  title: string;\n  producer: string;\n  creator: string;\n  durationSeconds: number;\n  quality: string;\n  orientation: string;\n  thumbnailPath: string;\n  publicViewCount: number;\n  publicUpvoteCount: number;\n  createdAt: Date;\n\n  // Derived fields\n  category: string; // From tags or directoryType\n  isPublic: boolean; // Computed: movedFromPublicAt === null\n}\n

Privacy: Never expose path, filename, fileHash, or internal metadata publicly.

"},{"location":"v2/features/media/public-gallery/#reactions-table","title":"Reactions Table","text":"
CREATE TABLE video_reactions (\n  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n  video_id UUID NOT NULL REFERENCES videos(id),\n  reaction_type TEXT NOT NULL, -- like|love|laugh|surprise|sad|angry\n  session_id TEXT NOT NULL, -- IP hash or session cookie\n  created_at TIMESTAMP DEFAULT NOW(),\n  UNIQUE(video_id, session_id) -- One reaction per user per video\n);\n\nCREATE INDEX idx_reactions_video ON video_reactions(video_id);\nCREATE INDEX idx_reactions_session ON video_reactions(session_id);\n

Reaction Types:

Session Tracking:

// Use IP hash for anonymous users\nconst sessionId = crypto.createHash('sha256').update(req.ip).digest('hex');\n\n// Or use cookie for persistent tracking\nconst sessionId = req.cookies.sessionId || randomUUID();\nres.cookie('sessionId', sessionId, { maxAge: 365 * 24 * 60 * 60 * 1000 }); // 1 year\n
"},{"location":"v2/features/media/public-gallery/#comments-table","title":"Comments Table","text":"
CREATE TABLE video_comments (\n  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n  video_id UUID NOT NULL REFERENCES videos(id),\n  name TEXT NOT NULL,\n  email TEXT, -- Optional, for moderation notifications\n  comment TEXT NOT NULL,\n  approved BOOLEAN DEFAULT FALSE, -- Moderation flag\n  session_id TEXT, -- For tracking duplicate comments\n  created_at TIMESTAMP DEFAULT NOW()\n);\n\nCREATE INDEX idx_comments_video ON video_comments(video_id);\nCREATE INDEX idx_comments_approved ON video_comments(approved);\n

Moderation Workflow:

  1. User submits comment \u2192 stored with approved = false
  2. Admin reviews comment in moderation dashboard
  3. Admin clicks \"Approve\" \u2192 approved = true, comment visible
  4. Admin clicks \"Reject\" \u2192 comment remains hidden or deleted
"},{"location":"v2/features/media/public-gallery/#view-logs-table","title":"View Logs Table","text":"
CREATE TABLE video_view_logs (\n  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n  video_id UUID NOT NULL REFERENCES videos(id),\n  session_id TEXT NOT NULL,\n  watch_time_seconds INTEGER DEFAULT 0, -- Actual watch time (not video duration)\n  completed BOOLEAN DEFAULT FALSE, -- Watched > 90%\n  created_at TIMESTAMP DEFAULT NOW()\n);\n\nCREATE INDEX idx_view_logs_video ON video_view_logs(video_id);\nCREATE INDEX idx_view_logs_session ON video_view_logs(session_id, video_id);\n

Watch Time Tracking:

// Frontend sends watch time on pause/end\nlet watchTime = 0;\nconst interval = setInterval(() => {\n  if (!player.paused) {\n    watchTime++;\n  }\n}, 1000);\n\n// On pause or end\nconst handlePause = async () => {\n  await axios.post(`/api/public/media/${videoId}/view`, {\n    watchTimeSeconds: watchTime,\n    completed: watchTime >= video.durationSeconds * 0.9,\n  });\n};\n
"},{"location":"v2/features/media/public-gallery/#api-endpoints-public","title":"API Endpoints (Public)","text":"

All endpoints are public (no authentication required).

"},{"location":"v2/features/media/public-gallery/#list-public-videos","title":"List Public Videos","text":"
GET /api/public/media\n

Query Parameters:

Parameter Type Default Description page number 1 Page number limit number 24 Results per page category string - Filter by category orientation string - Filter by orientation (portrait/landscape/square) quality string - Filter by quality (SD/HD/FHD/UHD) sort string recent Sort by: recent, popular, trending

Response:

{\n  \"data\": [\n    {\n      \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n      \"title\": \"Amazing Sports Highlight\",\n      \"producer\": \"Studio A\",\n      \"creator\": \"Director B\",\n      \"durationSeconds\": 125,\n      \"quality\": \"FHD\",\n      \"orientation\": \"landscape\",\n      \"thumbnailPath\": \"/media/thumbnails/550e8400.jpg\",\n      \"publicViewCount\": 1250,\n      \"publicUpvoteCount\": 85,\n      \"category\": \"Sports\",\n      \"createdAt\": \"2026-02-10T12:00:00Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 24,\n    \"total\": 156,\n    \"totalPages\": 7\n  }\n}\n

Caching:

// Cache public video lists for 5 minutes\nconst cacheKey = `public:videos:${JSON.stringify(query)}`;\nconst cached = await redisClient.get(cacheKey);\nif (cached) {\n  return reply.send(JSON.parse(cached));\n}\n\n// Fetch from database\nconst videos = await db.select()...;\n\n// Cache for 5 minutes\nawait redisClient.setex(cacheKey, 300, JSON.stringify(videos));\n
"},{"location":"v2/features/media/public-gallery/#get-video-details","title":"Get Video Details","text":"
GET /api/public/media/:id\n

Response:

{\n  \"video\": {\n    \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n    \"title\": \"Amazing Sports Highlight\",\n    \"producer\": \"Studio A\",\n    \"creator\": \"Director B\",\n    \"durationSeconds\": 125,\n    \"quality\": \"FHD\",\n    \"orientation\": \"landscape\",\n    \"width\": 1920,\n    \"height\": 1080,\n    \"thumbnailPath\": \"/media/thumbnails/550e8400.jpg\",\n    \"publicViewCount\": 1251,\n    \"publicUpvoteCount\": 85,\n    \"category\": \"Sports\",\n    \"createdAt\": \"2026-02-10T12:00:00Z\",\n    \"reactions\": {\n      \"like\": 45,\n      \"love\": 20,\n      \"laugh\": 10,\n      \"surprise\": 5,\n      \"sad\": 3,\n      \"angry\": 2\n    }\n  },\n  \"relatedVideos\": [\n    {\n      \"id\": \"660e8400-e29b-41d4-a716-446655440001\",\n      \"title\": \"Another Sports Video\",\n      \"thumbnailPath\": \"/media/thumbnails/660e8400.jpg\",\n      \"durationSeconds\": 90\n    },\n    {\n      \"id\": \"770e8400-e29b-41d4-a716-446655440002\",\n      \"title\": \"Top Plays Compilation\",\n      \"thumbnailPath\": \"/media/thumbnails/770e8400.jpg\",\n      \"durationSeconds\": 180\n    }\n  ],\n  \"comments\": [\n    {\n      \"id\": \"880e8400-e29b-41d4-a716-446655440003\",\n      \"name\": \"John Doe\",\n      \"comment\": \"Amazing video!\",\n      \"createdAt\": \"2026-02-12T14:30:00Z\"\n    }\n  ]\n}\n

Related Videos Algorithm:

// Find 3 similar videos\nconst relatedVideos = await db.select()\n  .from(videos)\n  .where(\n    and(\n      eq(videos.isPublic, true),\n      eq(videos.category, video.category), // Same category\n      not(eq(videos.id, video.id)) // Not current video\n    )\n  )\n  .orderBy(desc(videos.publicViewCount)) // Most popular first\n  .limit(3);\n
"},{"location":"v2/features/media/public-gallery/#track-video-view","title":"Track Video View","text":"
POST /api/public/media/:id/view\n

Request Body:

{\n  \"watchTimeSeconds\": 120,\n  \"completed\": true\n}\n

Response:

{\n  \"success\": true,\n  \"newViewCount\": 1252\n}\n

Process:

  1. Get session ID (IP hash or cookie)
  2. Check if already viewed in last 24 hours (prevent duplicate counting)
  3. Create view log record
  4. Increment video publicViewCount
  5. Return new view count
"},{"location":"v2/features/media/public-gallery/#addupdate-reaction","title":"Add/Update Reaction","text":"
POST /api/public/media/:id/reaction\n

Request Body:

{\n  \"reactionType\": \"like\"\n}\n

Response:

{\n  \"success\": true,\n  \"reactions\": {\n    \"like\": 46,\n    \"love\": 20,\n    \"laugh\": 10,\n    \"surprise\": 5,\n    \"sad\": 3,\n    \"angry\": 2\n  }\n}\n

Process:

  1. Get session ID
  2. Check if user already reacted
  3. If same reaction, remove it (toggle off)
  4. If different reaction, update it
  5. If no reaction, insert new one
  6. Return updated reaction counts
"},{"location":"v2/features/media/public-gallery/#submit-comment","title":"Submit Comment","text":"
POST /api/public/media/:id/comment\n

Request Body:

{\n  \"name\": \"John Doe\",\n  \"email\": \"john@example.com\",\n  \"comment\": \"This video is amazing! Thanks for sharing.\"\n}\n

Response:

{\n  \"success\": true,\n  \"message\": \"Comment submitted for moderation\"\n}\n

Validation:

Anti-Spam:

"},{"location":"v2/features/media/public-gallery/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/media/public-gallery/#sharing-videos-making-public","title":"Sharing Videos (Making Public)","text":"
  1. Navigate to Media \u2192 Shared Media page
  2. Table shows all videos with \"Public\" toggle switch
  3. To share video:
  4. Click toggle switch to ON (blue)
  5. Video immediately appears in public gallery
  6. Modal prompts for category selection (optional)
  7. To unshare video:
  8. Click toggle switch to OFF (grey)
  9. Video removed from public gallery
  10. movedFromPublicAt timestamp set (preserves history)

Shared Media Page Features:

"},{"location":"v2/features/media/public-gallery/#setting-categories","title":"Setting Categories","text":"

Option 1: Tag-Based Categories

Use video tags to auto-assign categories:

// If video has \"sports\" tag \u2192 Sports category\n// If video has \"education\" or \"tutorial\" tag \u2192 Education category\nconst detectCategory = (tags: string[]): string => {\n  if (tags.some(t => ['sports', 'game', 'play'].includes(t.toLowerCase()))) {\n    return 'Sports';\n  }\n  if (tags.some(t => ['education', 'tutorial', 'learn'].includes(t.toLowerCase()))) {\n    return 'Education';\n  }\n  if (tags.some(t => ['entertainment', 'comedy', 'music'].includes(t.toLowerCase()))) {\n    return 'Entertainment';\n  }\n  return 'Other';\n};\n

Option 2: Manual Assignment

  1. Select video in Shared Media page
  2. Click \"Edit Category\" button
  3. Modal opens with category dropdown:
  4. Entertainment
  5. Education
  6. Sports
  7. News
  8. Music
  9. Gaming
  10. Science & Tech
  11. Travel
  12. Other
  13. Click \"Save\"
  14. Category updated immediately
"},{"location":"v2/features/media/public-gallery/#viewing-statistics","title":"Viewing Statistics","text":"

Per-Video Stats:

  1. Click video row in Shared Media page
  2. Stats drawer slides in from right showing:
  3. Total Views \u2014 All-time view count
  4. Average Watch Time \u2014 Mean watch time (seconds)
  5. Completion Rate \u2014 % of viewers who watched > 90%
  6. Upvotes \u2014 Total upvote count
  7. Reactions Breakdown \u2014 Chart showing reaction distribution
  8. Top Referrers \u2014 Where views came from (direct, social, etc.)
  9. View Trend \u2014 Line chart of views over last 30 days

Gallery-Wide Stats:

Dashboard widget showing:

"},{"location":"v2/features/media/public-gallery/#moderating-comments","title":"Moderating Comments","text":"
  1. Navigate to Media \u2192 Comments page (or notification badge in sidebar)
  2. Table shows all comments with filters:
  3. Pending \u2014 Awaiting moderation
  4. Approved \u2014 Visible on public gallery
  5. Rejected \u2014 Hidden from public
  6. To approve comment:
  7. Click \"Approve\" button
  8. Comment appears on video page immediately
  9. To reject comment:
  10. Click \"Reject\" button
  11. Comment hidden (or deleted)
  12. Optional: Send email to commenter explaining why

Bulk Moderation:

"},{"location":"v2/features/media/public-gallery/#public-user-workflow","title":"Public User Workflow","text":""},{"location":"v2/features/media/public-gallery/#browsing-gallery","title":"Browsing Gallery","text":"
  1. Navigate to https://cmlite.org/media
  2. Hero section shows featured video (most popular or admin-selected)
  3. Category tabs below hero:
  4. All
  5. Entertainment
  6. Education
  7. Sports
  8. News
  9. Music
  10. Gaming
  11. Science & Tech
  12. Grid of video cards (4 per row on desktop, 2 on tablet, 1 on mobile)
  13. Each card shows:
  14. Thumbnail image
  15. Title
  16. Producer/creator
  17. Duration badge
  18. View count
  19. Quality badge (HD, FHD, UHD)

Infinite Scroll:

"},{"location":"v2/features/media/public-gallery/#watching-video","title":"Watching Video","text":"
  1. Click video card \u2192 navigates to https://cmlite.org/media/:id
  2. Video player page layout:
  3. Video Player \u2014 Full-width HTML5 player with controls
  4. Video Title & Metadata \u2014 Title, producer, creator, view count
  5. Reaction Bar \u2014 6 emoji buttons with counts
  6. Description \u2014 Auto-generated or admin-provided
  7. Comments Section \u2014 Approved comments + submit form
  8. Related Videos \u2014 3 similar videos in sidebar
  9. User clicks play \u2192 video starts, watch time tracked
  10. User clicks reaction \u2192 emoji highlighted, count increments
  11. User scrolls to comments \u2192 reads existing, submits new

Video Player Features:

"},{"location":"v2/features/media/public-gallery/#reacting-to-video","title":"Reacting to Video","text":"
  1. Click reaction emoji button (e.g., \ud83d\udc4d Like)
  2. Button highlights in color
  3. Count increments by 1
  4. Toggle behavior:
  5. Click again \u2192 removes reaction, count decrements
  6. Click different emoji \u2192 switches reaction
  7. Session tracked via cookie (reactions persist across page refreshes)

Reaction Colors:

"},{"location":"v2/features/media/public-gallery/#commenting","title":"Commenting","text":"
  1. Scroll to comments section below video
  2. Fill out form:
  3. Name \u2014 Required, displayed publicly
  4. Email \u2014 Optional, for moderation notifications
  5. Comment \u2014 Required, 1-1000 characters
  6. Click \"Submit Comment\"
  7. Success message: \"Comment submitted for moderation\"
  8. Comment appears in list with \"Pending approval\" badge
  9. After admin approval, comment visible to all

Comment Formatting:

"},{"location":"v2/features/media/public-gallery/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/media/public-gallery/#backend-list-public-videos","title":"Backend: List Public Videos","text":"
// api/src/modules/media/routes/public.routes.ts\nimport { FastifyInstance } from 'fastify';\nimport { eq, and, isNull, desc } from 'drizzle-orm';\nimport { videos } from '@/modules/media/db/schema';\nimport { redisClient } from '@/config/redis';\n\nexport default async function (app: FastifyInstance) {\n  app.get('/api/public/media', async (req, reply) => {\n    const {\n      page = 1,\n      limit = 24,\n      category,\n      orientation,\n      quality,\n      sort = 'recent',\n    } = req.query as any;\n\n    // Check cache\n    const cacheKey = `public:videos:${JSON.stringify(req.query)}`;\n    const cached = await redisClient.get(cacheKey);\n    if (cached) {\n      return reply.send(JSON.parse(cached));\n    }\n\n    // Build filters\n    const filters = [\n      isNull(videos.movedFromPublicAt), // Only public videos\n      eq(videos.isValid, true),\n    ];\n\n    if (category) {\n      filters.push(eq(videos.category, category));\n    }\n\n    if (orientation) {\n      filters.push(eq(videos.orientation, orientation));\n    }\n\n    if (quality) {\n      filters.push(eq(videos.quality, quality));\n    }\n\n    // Build order by\n    let orderBy;\n    if (sort === 'popular') {\n      orderBy = desc(videos.publicViewCount);\n    } else if (sort === 'trending') {\n      // Trending = highest view count in last 7 days\n      // (requires separate view_logs aggregation query)\n      orderBy = desc(videos.publicViewCount);\n    } else {\n      orderBy = desc(videos.createdAt);\n    }\n\n    // Fetch videos\n    const results = await db\n      .select({\n        id: videos.id,\n        title: videos.title,\n        producer: videos.producer,\n        creator: videos.creator,\n        durationSeconds: videos.durationSeconds,\n        quality: videos.quality,\n        orientation: videos.orientation,\n        thumbnailPath: videos.thumbnailPath,\n        publicViewCount: videos.publicViewCount,\n        publicUpvoteCount: videos.publicUpvoteCount,\n        category: videos.category,\n        createdAt: videos.createdAt,\n      })\n      .from(videos)\n      .where(and(...filters))\n      .orderBy(orderBy)\n      .limit(Number(limit))\n      .offset((Number(page) - 1) * Number(limit));\n\n    // Count total\n    const [{ count }] = await db\n      .select({ count: sql<number>`count(*)` })\n      .from(videos)\n      .where(and(...filters));\n\n    const response = {\n      data: results,\n      pagination: {\n        page: Number(page),\n        limit: Number(limit),\n        total: Number(count),\n        totalPages: Math.ceil(Number(count) / Number(limit)),\n      },\n    };\n\n    // Cache for 5 minutes\n    await redisClient.setex(cacheKey, 300, JSON.stringify(response));\n\n    reply.send(response);\n  });\n}\n
"},{"location":"v2/features/media/public-gallery/#backend-track-view","title":"Backend: Track View","text":"
// api/src/modules/media/routes/public.routes.ts\nimport { videoViewLogs, videos } from '@/modules/media/db/schema';\nimport crypto from 'crypto';\n\napp.post('/api/public/media/:id/view', async (req, reply) => {\n  const { id } = req.params as { id: string };\n  const { watchTimeSeconds, completed } = req.body as any;\n\n  // Get session ID from IP hash\n  const sessionId = crypto.createHash('sha256').update(req.ip).digest('hex');\n\n  // Check if already viewed in last 24 hours\n  const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);\n  const existingView = await db\n    .select()\n    .from(videoViewLogs)\n    .where(\n      and(\n        eq(videoViewLogs.videoId, id),\n        eq(videoViewLogs.sessionId, sessionId),\n        gte(videoViewLogs.createdAt, yesterday)\n      )\n    )\n    .limit(1);\n\n  if (existingView.length > 0) {\n    // Update watch time if longer than previous\n    if (watchTimeSeconds > existingView[0].watchTimeSeconds) {\n      await db\n        .update(videoViewLogs)\n        .set({\n          watchTimeSeconds,\n          completed: completed || existingView[0].completed,\n        })\n        .where(eq(videoViewLogs.id, existingView[0].id));\n    }\n\n    return reply.send({ success: true, newViewCount: null });\n  }\n\n  // Create new view log\n  await db.insert(videoViewLogs).values({\n    videoId: id,\n    sessionId,\n    watchTimeSeconds,\n    completed,\n  });\n\n  // Increment view count\n  const [updated] = await db\n    .update(videos)\n    .set({\n      publicViewCount: sql`${videos.publicViewCount} + 1`,\n    })\n    .where(eq(videos.id, id))\n    .returning({ newViewCount: videos.publicViewCount });\n\n  reply.send({ success: true, newViewCount: updated.newViewCount });\n});\n
"},{"location":"v2/features/media/public-gallery/#backend-add-reaction","title":"Backend: Add Reaction","text":"
// api/src/modules/media/routes/public.routes.ts\nimport { videoReactions } from '@/modules/media/db/schema';\n\napp.post('/api/public/media/:id/reaction', async (req, reply) => {\n  const { id } = req.params as { id: string };\n  const { reactionType } = req.body as { reactionType: string };\n\n  const validReactions = ['like', 'love', 'laugh', 'surprise', 'sad', 'angry'];\n  if (!validReactions.includes(reactionType)) {\n    return reply.code(400).send({ error: 'Invalid reaction type' });\n  }\n\n  const sessionId = crypto.createHash('sha256').update(req.ip).digest('hex');\n\n  // Check existing reaction\n  const [existing] = await db\n    .select()\n    .from(videoReactions)\n    .where(\n      and(\n        eq(videoReactions.videoId, id),\n        eq(videoReactions.sessionId, sessionId)\n      )\n    )\n    .limit(1);\n\n  if (existing) {\n    if (existing.reactionType === reactionType) {\n      // Toggle off (remove reaction)\n      await db\n        .delete(videoReactions)\n        .where(eq(videoReactions.id, existing.id));\n    } else {\n      // Update to new reaction\n      await db\n        .update(videoReactions)\n        .set({ reactionType })\n        .where(eq(videoReactions.id, existing.id));\n    }\n  } else {\n    // Insert new reaction\n    await db.insert(videoReactions).values({\n      videoId: id,\n      sessionId,\n      reactionType,\n    });\n  }\n\n  // Get updated reaction counts\n  const reactions = await db\n    .select({\n      reactionType: videoReactions.reactionType,\n      count: sql<number>`count(*)`,\n    })\n    .from(videoReactions)\n    .where(eq(videoReactions.videoId, id))\n    .groupBy(videoReactions.reactionType);\n\n  const reactionCounts = validReactions.reduce((acc, type) => {\n    acc[type] = reactions.find((r) => r.reactionType === type)?.count || 0;\n    return acc;\n  }, {} as Record<string, number>);\n\n  reply.send({ success: true, reactions: reactionCounts });\n});\n
"},{"location":"v2/features/media/public-gallery/#frontend-video-gallery-page","title":"Frontend: Video Gallery Page","text":"
// admin/src/pages/public/MediaGalleryPage.tsx\nimport { Row, Col, Card, Tag, Tabs, Empty } from 'antd';\nimport { PlayCircleOutlined, EyeOutlined } from '@ant-design/icons';\nimport { useEffect, useState } from 'react';\nimport axios from 'axios';\nimport InfiniteScroll from 'react-infinite-scroll-component';\n\nexport default function MediaGalleryPage() {\n  const [videos, setVideos] = useState<any[]>([]);\n  const [category, setCategory] = useState<string>('');\n  const [page, setPage] = useState(1);\n  const [hasMore, setHasMore] = useState(true);\n\n  const fetchVideos = async () => {\n    try {\n      const { data } = await axios.get('http://api.cmlite.org/api/public/media', {\n        params: {\n          page,\n          limit: 24,\n          category: category || undefined,\n        },\n      });\n\n      setVideos((prev) => [...prev, ...data.data]);\n      setHasMore(page < data.pagination.totalPages);\n    } catch (error) {\n      console.error('Failed to fetch videos:', error);\n    }\n  };\n\n  useEffect(() => {\n    setVideos([]);\n    setPage(1);\n    setHasMore(true);\n  }, [category]);\n\n  useEffect(() => {\n    fetchVideos();\n  }, [page, category]);\n\n  const categories = [\n    { key: '', label: 'All' },\n    { key: 'Entertainment', label: 'Entertainment' },\n    { key: 'Education', label: 'Education' },\n    { key: 'Sports', label: 'Sports' },\n    { key: 'News', label: 'News' },\n    { key: 'Music', label: 'Music' },\n    { key: 'Gaming', label: 'Gaming' },\n    { key: 'Science & Tech', label: 'Science & Tech' },\n  ];\n\n  return (\n    <div style={{ padding: 24 }}>\n      <h1 style={{ fontSize: 32, marginBottom: 24 }}>Video Gallery</h1>\n\n      <Tabs\n        activeKey={category}\n        onChange={setCategory}\n        items={categories.map((cat) => ({\n          key: cat.key,\n          label: cat.label,\n        }))}\n        style={{ marginBottom: 24 }}\n      />\n\n      <InfiniteScroll\n        dataLength={videos.length}\n        next={() => setPage((p) => p + 1)}\n        hasMore={hasMore}\n        loader={<div style={{ textAlign: 'center', padding: 24 }}>Loading...</div>}\n        endMessage={\n          <Empty description=\"No more videos\" style={{ marginTop: 48 }} />\n        }\n      >\n        <Row gutter={[16, 16]}>\n          {videos.map((video) => (\n            <Col key={video.id} xs={24} sm={12} md={8} lg={6}>\n              <Card\n                hoverable\n                cover={\n                  <div\n                    style={{\n                      position: 'relative',\n                      paddingTop: '56.25%',\n                      background: '#000',\n                    }}\n                  >\n                    <img\n                      src={video.thumbnailPath || '/placeholder.jpg'}\n                      alt={video.title}\n                      style={{\n                        position: 'absolute',\n                        top: 0,\n                        left: 0,\n                        width: '100%',\n                        height: '100%',\n                        objectFit: 'cover',\n                      }}\n                    />\n                    <div\n                      style={{\n                        position: 'absolute',\n                        top: 8,\n                        right: 8,\n                        background: 'rgba(0,0,0,0.7)',\n                        color: '#fff',\n                        padding: '4px 8px',\n                        borderRadius: 4,\n                        fontSize: 12,\n                      }}\n                    >\n                      {Math.floor(video.durationSeconds / 60)}:\n                      {(video.durationSeconds % 60).toString().padStart(2, '0')}\n                    </div>\n                    <PlayCircleOutlined\n                      style={{\n                        position: 'absolute',\n                        top: '50%',\n                        left: '50%',\n                        transform: 'translate(-50%, -50%)',\n                        fontSize: 48,\n                        color: '#fff',\n                        opacity: 0.8,\n                      }}\n                    />\n                  </div>\n                }\n                onClick={() => (window.location.href = `/media/${video.id}`)}\n              >\n                <Card.Meta\n                  title={\n                    <div style={{ fontSize: 14, height: 40, overflow: 'hidden' }}>\n                      {video.title}\n                    </div>\n                  }\n                  description={\n                    <div>\n                      <div style={{ fontSize: 12, color: '#888', marginBottom: 8 }}>\n                        {video.producer}\n                      </div>\n                      <div style={{ display: 'flex', justifyContent: 'space-between' }}>\n                        <span style={{ fontSize: 12 }}>\n                          <EyeOutlined /> {video.publicViewCount.toLocaleString()}\n                        </span>\n                        <Tag color={video.quality === 'UHD' ? 'purple' : 'blue'}>\n                          {video.quality}\n                        </Tag>\n                      </div>\n                    </div>\n                  }\n                />\n              </Card>\n            </Col>\n          ))}\n        </Row>\n      </InfiniteScroll>\n    </div>\n  );\n}\n
"},{"location":"v2/features/media/public-gallery/#frontend-video-player-page","title":"Frontend: Video Player Page","text":"
// admin/src/pages/public/MediaViewerPage.tsx\nimport { useParams } from 'react-router-dom';\nimport { useEffect, useState } from 'react';\nimport axios from 'axios';\nimport ReactPlayer from 'react-player';\nimport { Button, Row, Col, Card, Divider, Form, Input, message } from 'antd';\n\nexport default function MediaViewerPage() {\n  const { id } = useParams<{ id: string }>();\n  const [video, setVideo] = useState<any>(null);\n  const [watchTime, setWatchTime] = useState(0);\n  const [userReaction, setUserReaction] = useState<string | null>(null);\n\n  useEffect(() => {\n    fetchVideo();\n  }, [id]);\n\n  const fetchVideo = async () => {\n    const { data } = await axios.get(`http://api.cmlite.org/api/public/media/${id}`);\n    setVideo(data.video);\n  };\n\n  const trackView = async () => {\n    await axios.post(`http://api.cmlite.org/api/public/media/${id}/view`, {\n      watchTimeSeconds: watchTime,\n      completed: watchTime >= video.durationSeconds * 0.9,\n    });\n  };\n\n  const handleReaction = async (reactionType: string) => {\n    const { data } = await axios.post(`http://api.cmlite.org/api/public/media/${id}/reaction`, {\n      reactionType,\n    });\n\n    setUserReaction(userReaction === reactionType ? null : reactionType);\n    setVideo({ ...video, reactions: data.reactions });\n  };\n\n  const handleSubmitComment = async (values: any) => {\n    await axios.post(`http://api.cmlite.org/api/public/media/${id}/comment`, values);\n    message.success('Comment submitted for moderation');\n  };\n\n  if (!video) return <div>Loading...</div>;\n\n  const reactions = [\n    { type: 'like', emoji: '\ud83d\udc4d', label: 'Like' },\n    { type: 'love', emoji: '\u2764\ufe0f', label: 'Love' },\n    { type: 'laugh', emoji: '\ud83d\ude02', label: 'Laugh' },\n    { type: 'surprise', emoji: '\ud83d\ude2e', label: 'Surprise' },\n    { type: 'sad', emoji: '\ud83d\ude22', label: 'Sad' },\n    { type: 'angry', emoji: '\ud83d\ude20', label: 'Angry' },\n  ];\n\n  return (\n    <div style={{ maxWidth: 1200, margin: '0 auto', padding: 24 }}>\n      <Row gutter={24}>\n        <Col span={16}>\n          <ReactPlayer\n            url={`/media/videos/${video.id}.mp4`}\n            controls\n            width=\"100%\"\n            height=\"auto\"\n            onProgress={(state) => setWatchTime(Math.floor(state.playedSeconds))}\n            onPause={trackView}\n            onEnded={trackView}\n          />\n\n          <h1 style={{ marginTop: 16 }}>{video.title}</h1>\n          <div style={{ color: '#888', marginBottom: 16 }}>\n            {video.producer} \u2022 {video.publicViewCount.toLocaleString()} views\n          </div>\n\n          <div style={{ display: 'flex', gap: 8, marginBottom: 24 }}>\n            {reactions.map((r) => (\n              <Button\n                key={r.type}\n                type={userReaction === r.type ? 'primary' : 'default'}\n                onClick={() => handleReaction(r.type)}\n              >\n                <span style={{ fontSize: 20, marginRight: 4 }}>{r.emoji}</span>\n                {video.reactions[r.type] || 0}\n              </Button>\n            ))}\n          </div>\n\n          <Divider />\n\n          <h3>Comments</h3>\n          {video.comments.map((comment: any) => (\n            <Card key={comment.id} style={{ marginBottom: 16 }}>\n              <Card.Meta\n                title={comment.name}\n                description={comment.comment}\n              />\n              <div style={{ fontSize: 12, color: '#888', marginTop: 8 }}>\n                {new Date(comment.createdAt).toLocaleDateString()}\n              </div>\n            </Card>\n          ))}\n\n          <Form onFinish={handleSubmitComment} layout=\"vertical\">\n            <Form.Item label=\"Name\" name=\"name\" rules={[{ required: true }]}>\n              <Input />\n            </Form.Item>\n            <Form.Item label=\"Email\" name=\"email\" rules={[{ type: 'email' }]}>\n              <Input />\n            </Form.Item>\n            <Form.Item label=\"Comment\" name=\"comment\" rules={[{ required: true }]}>\n              <Input.TextArea rows={4} />\n            </Form.Item>\n            <Button type=\"primary\" htmlType=\"submit\">\n              Submit Comment\n            </Button>\n          </Form>\n        </Col>\n\n        <Col span={8}>\n          <h3>Related Videos</h3>\n          {video.relatedVideos.map((related: any) => (\n            <Card\n              key={related.id}\n              hoverable\n              cover={<img src={related.thumbnailPath} alt={related.title} />}\n              onClick={() => (window.location.href = `/media/${related.id}`)}\n              style={{ marginBottom: 16 }}\n            >\n              <Card.Meta title={related.title} />\n            </Card>\n          ))}\n        </Col>\n      </Row>\n    </div>\n  );\n}\n
"},{"location":"v2/features/media/public-gallery/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/media/public-gallery/#problem-videos-not-appearing-in-gallery","title":"Problem: Videos Not Appearing in Gallery","text":"

Symptoms:

Solutions:

  1. Check movedFromPublicAt field:
SELECT id, title, moved_from_public_at FROM videos WHERE moved_from_public_at IS NULL;\n-- Should show public videos\n\n-- If all have timestamps, videos were unlocked\n-- Fix: Set to NULL for videos that should be public\nUPDATE videos SET moved_from_public_at = NULL WHERE id = 'VIDEO_ID';\n
  1. Verify isValid = true:
SELECT id, title, is_valid FROM videos WHERE is_valid = false;\n-- Invalid videos hidden from public\n\n-- Fix: Validate videos to mark as valid\n
  1. Check Redis cache:
# Clear public video cache\ndocker compose exec redis redis-cli\n> KEYS public:videos:*\n> DEL public:videos:*\n\n# Refresh gallery page\n
  1. Test API directly:
curl http://localhost:4100/api/public/media\n# Should return JSON with videos array\n
"},{"location":"v2/features/media/public-gallery/#problem-reactions-not-saving","title":"Problem: Reactions Not Saving","text":"

Symptoms:

Solutions:

  1. Check session ID generation:
// Backend should use consistent session ID\nconst sessionId = crypto.createHash('sha256').update(req.ip).digest('hex');\n\n// Or use cookie for persistence\nconst sessionId = req.cookies.sessionId || randomUUID();\nres.cookie('sessionId', sessionId, { maxAge: 365 * 24 * 60 * 60 * 1000 });\n
  1. Verify database insert:
SELECT * FROM video_reactions WHERE video_id = 'VIDEO_ID';\n-- Should show reaction records\n\n-- If empty, insert is failing\n-- Check unique constraint: (video_id, session_id)\n
  1. Test reaction endpoint:
curl -X POST http://localhost:4100/api/public/media/VIDEO_ID/reaction \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"reactionType\": \"like\"}'\n\n# Should return updated reaction counts\n
"},{"location":"v2/features/media/public-gallery/#problem-comments-not-showing-after-approval","title":"Problem: Comments Not Showing After Approval","text":"

Symptoms:

Solutions:

  1. Check query filter:
// Backend should filter for approved comments\nconst comments = await db\n  .select()\n  .from(videoComments)\n  .where(\n    and(\n      eq(videoComments.videoId, videoId),\n      eq(videoComments.approved, true) // MUST include this\n    )\n  )\n  .orderBy(desc(videoComments.createdAt));\n
  1. Clear cache:
# Video details may be cached\ndocker compose exec redis redis-cli DEL \"public:video:VIDEO_ID\"\n
  1. Verify approval:
SELECT id, comment, approved FROM video_comments WHERE video_id = 'VIDEO_ID';\n-- Should show approved = true\n
"},{"location":"v2/features/media/public-gallery/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/media/public-gallery/#redis-caching-strategy","title":"Redis Caching Strategy","text":"

Cache Keys:

Cache Invalidation:

// When admin shares/unshares video\nawait redisClient.del(`public:videos:*`); // Clear all list caches\nawait redisClient.del(`public:video:${videoId}`); // Clear detail cache\n\n// When comment approved\nawait redisClient.del(`public:video:${videoId}`); // Refresh comments\n
"},{"location":"v2/features/media/public-gallery/#database-indexes","title":"Database Indexes","text":"
-- Public video queries\nCREATE INDEX idx_videos_public ON videos(moved_from_public_at) WHERE moved_from_public_at IS NULL;\nCREATE INDEX idx_videos_category ON videos(category, created_at DESC);\nCREATE INDEX idx_videos_popular ON videos(public_view_count DESC);\n\n-- Reactions\nCREATE INDEX idx_reactions_video ON video_reactions(video_id);\nCREATE INDEX idx_reactions_session ON video_reactions(session_id);\n\n-- Comments\nCREATE INDEX idx_comments_video_approved ON video_comments(video_id, approved);\n\n-- View logs\nCREATE INDEX idx_view_logs_video ON video_view_logs(video_id);\nCREATE INDEX idx_view_logs_recent ON video_view_logs(created_at DESC);\n
"},{"location":"v2/features/media/public-gallery/#seo-optimization","title":"SEO Optimization","text":"

Server-Side Rendering (Future):

// Next.js or similar for SSR\nexport async function getServerSideProps({ params }: { params: { id: string } }) {\n  const video = await fetchVideo(params.id);\n\n  return {\n    props: {\n      video,\n      meta: {\n        title: video.title,\n        description: `Watch ${video.title} by ${video.producer}`,\n        image: video.thumbnailPath,\n        url: `https://cmlite.org/media/${video.id}`,\n      },\n    },\n  };\n}\n

Meta Tags:

<head>\n  <title>Amazing Sports Highlight | CMLite Gallery</title>\n  <meta name=\"description\" content=\"Watch Amazing Sports Highlight by Studio A. 1,250 views.\">\n  <meta property=\"og:title\" content=\"Amazing Sports Highlight\">\n  <meta property=\"og:description\" content=\"Watch Amazing Sports Highlight by Studio A\">\n  <meta property=\"og:image\" content=\"https://cmlite.org/media/thumbnails/550e8400.jpg\">\n  <meta property=\"og:url\" content=\"https://cmlite.org/media/550e8400\">\n  <meta property=\"og:type\" content=\"video.other\">\n  <meta name=\"twitter:card\" content=\"player\">\n  <meta name=\"twitter:title\" content=\"Amazing Sports Highlight\">\n  <meta name=\"twitter:image\" content=\"https://cmlite.org/media/thumbnails/550e8400.jpg\">\n</head>\n

Sitemap Generation:

<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n  <url>\n    <loc>https://cmlite.org/media</loc>\n    <changefreq>daily</changefreq>\n    <priority>1.0</priority>\n  </url>\n  <url>\n    <loc>https://cmlite.org/media/550e8400-e29b-41d4-a716-446655440000</loc>\n    <lastmod>2026-02-10</lastmod>\n    <changefreq>weekly</changefreq>\n    <priority>0.8</priority>\n  </url>\n  <!-- ... more video URLs -->\n</urlset>\n
"},{"location":"v2/features/media/public-gallery/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/features/media/public-gallery/#rate-limiting","title":"Rate Limiting","text":"
// Public endpoints more restrictive than admin\nimport rateLimit from '@fastify/rate-limit';\n\napp.register(rateLimit, {\n  max: 100,          // 100 requests\n  timeWindow: '1 minute',\n  allowList: [],     // No whitelist for public\n});\n

Per-Endpoint Limits:

"},{"location":"v2/features/media/public-gallery/#content-moderation","title":"Content Moderation","text":"

Comment Filtering:

import Filter from 'bad-words';\n\nconst filter = new Filter();\n\nconst sanitizeComment = (comment: string): string => {\n  // Remove HTML tags\n  const cleaned = comment.replace(/<[^>]*>/g, '');\n\n  // Filter profanity\n  return filter.clean(cleaned);\n};\n

Spam Detection:

// Reject duplicate comments\nconst existingComment = await db.select()\n  .from(videoComments)\n  .where(\n    and(\n      eq(videoComments.sessionId, sessionId),\n      eq(videoComments.comment, comment),\n      gte(videoComments.createdAt, new Date(Date.now() - 24 * 60 * 60 * 1000))\n    )\n  )\n  .limit(1);\n\nif (existingComment.length > 0) {\n  return reply.code(429).send({ error: 'Duplicate comment detected' });\n}\n
"},{"location":"v2/features/media/public-gallery/#privacy-protection","title":"Privacy Protection","text":"

Never Expose:

Session Tracking:

// Use IP hash (not raw IP) for session ID\nconst sessionId = crypto.createHash('sha256').update(req.ip + 'SECRET_SALT').digest('hex');\n\n// Store minimal data in session\n// NO: { userId: 123, name: 'John', email: 'john@example.com' }\n// YES: { sessionId: 'abc123' }\n
"},{"location":"v2/features/media/public-gallery/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/media/public-gallery/#backend-documentation","title":"Backend Documentation","text":""},{"location":"v2/features/media/public-gallery/#frontend-documentation","title":"Frontend Documentation","text":""},{"location":"v2/features/media/public-gallery/#feature-documentation","title":"Feature Documentation","text":""},{"location":"v2/features/media/public-gallery/#next-steps","title":"Next Steps","text":"

After mastering the public gallery:

  1. Analytics Dashboard \u2014 Build admin dashboard showing view trends, popular videos, engagement metrics
  2. Playlist System \u2014 Allow users to create and share playlists
  3. Video Embedding \u2014 Generate embed codes for external websites
  4. Advanced Search \u2014 Full-text search across titles, producers, creators, tags

Hands-On Practice:

# 1. Share video via API\ncurl -X PUT http://localhost:4100/api/media/videos/VIDEO_ID/share \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"category\": \"Sports\"}'\n\n# 2. Browse public gallery\ncurl http://localhost:4100/api/public/media?category=Sports\n\n# 3. Track view\ncurl -X POST http://localhost:4100/api/public/media/VIDEO_ID/view \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"watchTimeSeconds\": 120, \"completed\": true}'\n\n# 4. Add reaction\ncurl -X POST http://localhost:4100/api/public/media/VIDEO_ID/reaction \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"reactionType\": \"like\"}'\n

Last Updated: 2026-02-13 Version: V2.0 Maintainer: Changemaker Lite Team

"},{"location":"v2/features/media/upload/","title":"Video Upload System","text":""},{"location":"v2/features/media/upload/#overview","title":"Overview","text":"

The Video Upload System provides a modern drag-and-drop interface for uploading video files with automatic metadata extraction, progress tracking, and batch processing capabilities. Built on Fastify's multipart plugin with FFprobe integration, it supports large files up to 10GB while maintaining server stability through streaming.

Key Features:

Technology Stack:

"},{"location":"v2/features/media/upload/#architecture","title":"Architecture","text":"
sequenceDiagram\n    participant U as User\n    participant UI as UploadVideoModal\n    participant API as Fastify Media API\n    participant FS as Filesystem\n    participant FFP as FFprobe Service\n    participant DB as PostgreSQL\n\n    U->>UI: Drag video file(s)\n    UI->>UI: Validate file type/size\n    UI->>U: Show file in queue\n\n    U->>UI: Click \"Upload\"\n    UI->>API: POST /api/media/upload/single<br/>(multipart/form-data)\n\n    API->>API: Generate UUID filename\n    API->>FS: Stream to /inbox/{uuid}.mp4\n    FS-->>API: Write complete\n\n    API->>FFP: Extract metadata\n    FFP->>FS: Analyze video file\n    FFP-->>API: Return metadata JSON\n\n    API->>DB: INSERT video record\n    DB-->>API: Return video ID\n\n    API-->>UI: Upload success + metadata\n    UI-->>U: Show success message\n    UI->>UI: Refresh library table\n\n    Note over API,FS: File remains in /inbox<br/>until moved by admin

Upload Flow:

  1. Client Validation \u2014 Browser checks file extension and size before upload
  2. Streaming Upload \u2014 File streamed to disk in chunks (no memory buffer)
  3. Metadata Extraction \u2014 FFprobe analyzes video (30s timeout)
  4. Database Record \u2014 Video record created with auto-detected metadata
  5. Response \u2014 Frontend receives video ID and metadata
  6. Library Update \u2014 Table refreshes to show new video

Key Design Decisions:

"},{"location":"v2/features/media/upload/#upload-workflow","title":"Upload Workflow","text":""},{"location":"v2/features/media/upload/#user-workflow-admin","title":"User Workflow (Admin)","text":"
  1. Open Upload Modal
  2. Navigate to Media \u2192 Library page
  3. Click \"Upload Video\" button in top toolbar
  4. Modal opens with drag-drop zone

  5. Select Files

  6. Drag files from desktop into blue dashed zone
  7. OR click \"Click to browse\" link to open file picker
  8. Multiple files can be selected for batch upload

  9. Review Queue

  10. Selected files appear in list with:
  11. Invalid files (wrong extension, too large) highlighted in red

  12. Enter Metadata (Optional)

  13. Producer \u2014 Studio or production company name
  14. Creator \u2014 Director or primary creator
  15. Title \u2014 Display title (defaults to filename if blank)
  16. Tags \u2014 Comma-separated tags (e.g., \"action, sports, highlight\")

  17. Upload

  18. Click \"Upload\" button
  19. Files upload sequentially (not parallel)
  20. Progress bar shows:

  21. Metadata Extraction

  22. After upload completes, FFprobe runs automatically
  23. Spinner shows \"Extracting metadata...\"
  24. Auto-fills: duration, dimensions, orientation, quality, audio

  25. Success

  26. Green checkmark appears
  27. Success message: \"Uploaded: {filename}\"
  28. Modal can be closed or kept open for more uploads
  29. Library table refreshes showing new video
"},{"location":"v2/features/media/upload/#error-handling","title":"Error Handling","text":"

Invalid File Type:

Error: File type not supported\nAllowed: MP4, MOV, AVI, MKV, WebM, M4V, FLV\n

File Too Large:

Error: File exceeds 10GB limit\nSelected file: 12.5 GB\n

Upload Failed:

Error: Upload failed\nNetwork error or server unavailable\n

FFprobe Extraction Failed:

Warning: Metadata extraction failed\nVideo uploaded but metadata incomplete\nYou can manually enter duration and dimensions\n
"},{"location":"v2/features/media/upload/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/media/upload/#upload-single-video","title":"Upload Single Video","text":"
POST /api/media/upload/single\nContent-Type: multipart/form-data\nAuthorization: Bearer <admin_token>\n

Request (Multipart Form Data):

--boundary\nContent-Disposition: form-data; name=\"video\"; filename=\"my-video.mp4\"\nContent-Type: video/mp4\n\n<binary video data>\n--boundary\nContent-Disposition: form-data; name=\"producer\"\n\nStudio A\n--boundary\nContent-Disposition: form-data; name=\"creator\"\n\nDirector B\n--boundary\nContent-Disposition: form-data; name=\"title\"\n\nMy Awesome Video\n--boundary\nContent-Disposition: form-data; name=\"tags\"\n\naction,sports,highlight\n--boundary--\n

Response (Success):

{\n  \"id\": \"660e8400-e29b-41d4-a716-446655440000\",\n  \"path\": \"inbox/660e8400-e29b-41d4-a716-446655440000.mp4\",\n  \"filename\": \"660e8400-e29b-41d4-a716-446655440000.mp4\",\n  \"originalFilename\": \"my-video.mp4\",\n  \"directoryType\": \"inbox\",\n  \"producer\": \"Studio A\",\n  \"creator\": \"Director B\",\n  \"title\": \"My Awesome Video\",\n  \"tags\": [\"action\", \"sports\", \"highlight\"],\n  \"durationSeconds\": 125,\n  \"width\": 1920,\n  \"height\": 1080,\n  \"quality\": \"FHD\",\n  \"orientation\": \"landscape\",\n  \"hasAudio\": true,\n  \"fileSize\": 45678912,\n  \"isValid\": true,\n  \"createdAt\": \"2026-02-13T14:30:00Z\"\n}\n

Response (Error):

{\n  \"statusCode\": 400,\n  \"error\": \"Bad Request\",\n  \"message\": \"Invalid file type. Allowed: mp4, mov, avi, mkv, webm, m4v, flv\"\n}\n
"},{"location":"v2/features/media/upload/#upload-batch-multiple-videos","title":"Upload Batch (Multiple Videos)","text":"
POST /api/media/upload/batch\nContent-Type: multipart/form-data\nAuthorization: Bearer <admin_token>\n

Request:

--boundary\nContent-Disposition: form-data; name=\"videos\"; filename=\"video1.mp4\"\nContent-Type: video/mp4\n\n<binary data>\n--boundary\nContent-Disposition: form-data; name=\"videos\"; filename=\"video2.mp4\"\nContent-Type: video/mp4\n\n<binary data>\n--boundary\nContent-Disposition: form-data; name=\"producer\"\n\nStudio A\n--boundary--\n

Response:

{\n  \"uploaded\": 2,\n  \"failed\": 0,\n  \"results\": [\n    {\n      \"id\": \"660e8400-e29b-41d4-a716-446655440000\",\n      \"filename\": \"video1.mp4\",\n      \"status\": \"success\"\n    },\n    {\n      \"id\": \"770e8400-e29b-41d4-a716-446655440001\",\n      \"filename\": \"video2.mp4\",\n      \"status\": \"success\"\n    }\n  ]\n}\n
"},{"location":"v2/features/media/upload/#configuration","title":"Configuration","text":""},{"location":"v2/features/media/upload/#environment-variables","title":"Environment Variables","text":"
# Upload Limits\nMEDIA_MAX_FILE_SIZE=10737418240  # 10GB in bytes\nMEDIA_MAX_FILES_BATCH=10         # Max files per batch upload\n\n# Upload Paths\nMEDIA_INBOX_PATH=/media/local/inbox\nMEDIA_LIBRARY_PATH=/media/local/library\n\n# FFprobe\nFFPROBE_TIMEOUT=30000  # 30 seconds\nFFPROBE_PATH=/usr/bin/ffprobe  # Auto-detected if not set\n\n# Allowed Extensions (comma-separated)\nMEDIA_ALLOWED_EXTENSIONS=mp4,mov,avi,mkv,webm,m4v,flv\n
"},{"location":"v2/features/media/upload/#fastify-multipart-configuration","title":"Fastify Multipart Configuration","text":"
// api/src/media-server.ts\nimport multipart from '@fastify/multipart';\n\napp.register(multipart, {\n  limits: {\n    fieldNameSize: 100,      // Max field name size (bytes)\n    fieldSize: 1000000,      // Max field value size (bytes) - for text fields\n    fields: 10,              // Max number of non-file fields\n    fileSize: 10 * 1024 * 1024 * 1024, // 10GB max file size\n    files: 10,               // Max number of files per request\n    headerPairs: 2000,       // Max header key-value pairs\n  },\n  attachFieldsToBody: false, // Don't parse all fields into body (use req.file())\n});\n
"},{"location":"v2/features/media/upload/#docker-volume-mounts","title":"Docker Volume Mounts","text":"

Critical: Inbox directory must be mounted as read-write (:rw):

# docker-compose.yml\nservices:\n  media-api:\n    volumes:\n      - /media/local/library:/media/local/library:ro  # Read-only library\n      - /media/local/inbox:/media/local/inbox:rw      # READ-WRITE inbox\n

Without :rw suffix, uploads fail with permission errors.

"},{"location":"v2/features/media/upload/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/media/upload/#frontend-upload-modal-component","title":"Frontend: Upload Modal Component","text":"
// admin/src/components/media/UploadVideoModal.tsx\nimport { Modal, Upload, Form, Input, Button, Progress, message } from 'antd';\nimport { InboxOutlined } from '@ant-design/icons';\nimport { useState } from 'react';\nimport { mediaApi } from '@/lib/media-api';\n\ninterface UploadVideoModalProps {\n  visible: boolean;\n  onClose: () => void;\n  onSuccess: () => void;\n}\n\nexport default function UploadVideoModal({ visible, onClose, onSuccess }: UploadVideoModalProps) {\n  const [form] = Form.useForm();\n  const [fileList, setFileList] = useState<any[]>([]);\n  const [uploading, setUploading] = useState(false);\n  const [uploadProgress, setUploadProgress] = useState(0);\n\n  const handleUpload = async () => {\n    if (fileList.length === 0) {\n      message.error('Please select at least one video file');\n      return;\n    }\n\n    setUploading(true);\n\n    try {\n      const values = await form.validateFields();\n\n      for (const fileItem of fileList) {\n        const formData = new FormData();\n        formData.append('video', fileItem.originFileObj);\n        formData.append('producer', values.producer || '');\n        formData.append('creator', values.creator || '');\n        formData.append('title', values.title || fileItem.name);\n        formData.append('tags', values.tags || '');\n\n        const { data } = await mediaApi.post('/api/media/upload/single', formData, {\n          headers: {\n            'Content-Type': 'multipart/form-data',\n          },\n          onUploadProgress: (progressEvent) => {\n            const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total!);\n            setUploadProgress(percent);\n          },\n        });\n\n        message.success(`Uploaded: ${fileItem.name}`);\n      }\n\n      onSuccess();\n      handleClose();\n    } catch (error: any) {\n      message.error(error.response?.data?.message || 'Upload failed');\n    } finally {\n      setUploading(false);\n      setUploadProgress(0);\n    }\n  };\n\n  const handleClose = () => {\n    form.resetFields();\n    setFileList([]);\n    setUploadProgress(0);\n    onClose();\n  };\n\n  return (\n    <Modal\n      title=\"Upload Video\"\n      open={visible}\n      onCancel={handleClose}\n      footer={[\n        <Button key=\"cancel\" onClick={handleClose} disabled={uploading}>\n          Cancel\n        </Button>,\n        <Button key=\"upload\" type=\"primary\" onClick={handleUpload} loading={uploading}>\n          Upload\n        </Button>,\n      ]}\n      width={600}\n      destroyOnClose\n    >\n      <Upload.Dragger\n        multiple\n        fileList={fileList}\n        onChange={({ fileList }) => setFileList(fileList)}\n        beforeUpload={(file) => {\n          const isVideo = [\n            'video/mp4',\n            'video/quicktime',\n            'video/x-msvideo',\n            'video/x-matroska',\n            'video/webm',\n            'video/x-m4v',\n            'video/x-flv',\n          ].includes(file.type);\n\n          if (!isVideo) {\n            message.error(`${file.name} is not a supported video format`);\n            return Upload.LIST_IGNORE;\n          }\n\n          const isLt10GB = file.size / 1024 / 1024 / 1024 < 10;\n          if (!isLt10GB) {\n            message.error(`${file.name} exceeds 10GB limit`);\n            return Upload.LIST_IGNORE;\n          }\n\n          return false; // Prevent auto-upload\n        }}\n        disabled={uploading}\n      >\n        <p className=\"ant-upload-drag-icon\">\n          <InboxOutlined />\n        </p>\n        <p className=\"ant-upload-text\">Click or drag video files to this area</p>\n        <p className=\"ant-upload-hint\">\n          Supports MP4, MOV, AVI, MKV, WebM, M4V, FLV. Max 10GB per file.\n        </p>\n      </Upload.Dragger>\n\n      {uploading && (\n        <div style={{ marginTop: 16 }}>\n          <Progress percent={uploadProgress} status=\"active\" />\n        </div>\n      )}\n\n      <Form form={form} layout=\"vertical\" style={{ marginTop: 24 }}>\n        <Form.Item label=\"Producer\" name=\"producer\">\n          <Input placeholder=\"Studio or production company\" />\n        </Form.Item>\n\n        <Form.Item label=\"Creator\" name=\"creator\">\n          <Input placeholder=\"Director or creator name\" />\n        </Form.Item>\n\n        <Form.Item label=\"Title\" name=\"title\">\n          <Input placeholder=\"Display title (defaults to filename)\" />\n        </Form.Item>\n\n        <Form.Item label=\"Tags\" name=\"tags\">\n          <Input placeholder=\"Comma-separated tags (e.g., action, sports)\" />\n        </Form.Item>\n      </Form>\n    </Modal>\n  );\n}\n
"},{"location":"v2/features/media/upload/#backend-single-upload-route","title":"Backend: Single Upload Route","text":"
// api/src/modules/media/routes/upload.routes.ts\nimport { FastifyInstance } from 'fastify';\nimport path from 'path';\nimport fs from 'fs/promises';\nimport { randomUUID } from 'crypto';\nimport { db } from '@/modules/media/db';\nimport { videos } from '@/modules/media/db/schema';\nimport { ffprobeService } from '@/modules/media/services/ffprobe.service';\nimport { requireRole } from '@/middleware/auth';\n\nexport default async function (app: FastifyInstance) {\n  app.post(\n    '/api/media/upload/single',\n    {\n      preHandler: [requireRole('SUPER_ADMIN')],\n    },\n    async (req, reply) => {\n      try {\n        // Get uploaded file\n        const data = await req.file();\n\n        if (!data) {\n          return reply.code(400).send({ error: 'No file uploaded' });\n        }\n\n        // Validate file extension\n        const ext = path.extname(data.filename).toLowerCase();\n        const allowedExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv'];\n\n        if (!allowedExtensions.includes(ext)) {\n          return reply.code(400).send({\n            error: 'Invalid file type',\n            message: `Allowed extensions: ${allowedExtensions.join(', ')}`,\n          });\n        }\n\n        // Generate UUID filename\n        const uuid = randomUUID();\n        const filename = `${uuid}${ext}`;\n        const relativePath = `inbox/${filename}`;\n        const absolutePath = path.join(process.env.MEDIA_INBOX_PATH!, filename);\n\n        // Stream to disk\n        const writeStream = fs.createWriteStream(absolutePath);\n        await data.file.pipe(writeStream);\n\n        app.log.info(`File uploaded to ${absolutePath}`);\n\n        // Extract metadata\n        let metadata;\n        try {\n          metadata = await ffprobeService.extract(absolutePath);\n          app.log.info('FFprobe metadata extracted', metadata);\n        } catch (error: any) {\n          app.log.warn('FFprobe extraction failed', error);\n          // Continue without metadata (can be validated later)\n          metadata = {\n            duration: null,\n            width: null,\n            height: null,\n            orientation: null,\n            quality: null,\n            hasAudio: false,\n          };\n        }\n\n        // Get file size\n        const stats = await fs.stat(absolutePath);\n\n        // Parse metadata from request body\n        const body = data.fields as any;\n        const producer = body.producer?.value || null;\n        const creator = body.creator?.value || null;\n        const title = body.title?.value || data.filename;\n        const tagsString = body.tags?.value || '';\n        const tags = tagsString\n          ? tagsString.split(',').map((t: string) => t.trim())\n          : [];\n\n        // Create database record\n        const [video] = await db\n          .insert(videos)\n          .values({\n            path: relativePath,\n            filename,\n            originalFilename: data.filename,\n            directoryType: 'inbox',\n            producer,\n            creator,\n            title,\n            tags,\n            durationSeconds: metadata.duration,\n            width: metadata.width,\n            height: metadata.height,\n            orientation: metadata.orientation,\n            quality: metadata.quality,\n            hasAudio: metadata.hasAudio,\n            fileSize: stats.size,\n            isValid: true,\n          })\n          .returning();\n\n        reply.send(video);\n      } catch (error: any) {\n        app.log.error('Upload failed', error);\n        reply.code(500).send({\n          error: 'Upload failed',\n          message: error.message,\n        });\n      }\n    }\n  );\n}\n
"},{"location":"v2/features/media/upload/#ffprobe-metadata-extraction","title":"FFprobe Metadata Extraction","text":"
// api/src/modules/media/services/ffprobe.service.ts\nimport { exec } from 'child_process';\nimport { promisify } from 'util';\n\nconst execAsync = promisify(exec);\n\ninterface VideoMetadata {\n  duration: number | null;\n  width: number | null;\n  height: number | null;\n  orientation: string | null;\n  quality: string | null;\n  hasAudio: boolean;\n  fileSize: number | null;\n  fileHash: string | null;\n}\n\nexport class FFprobeService {\n  private timeout = parseInt(process.env.FFPROBE_TIMEOUT || '30000', 10);\n  private ffprobePath = process.env.FFPROBE_PATH || 'ffprobe';\n\n  async extract(filePath: string): Promise<VideoMetadata> {\n    try {\n      const command = `${this.ffprobePath} -v quiet -print_format json -show_streams -show_format \"${filePath}\"`;\n\n      const { stdout } = await execAsync(command, {\n        timeout: this.timeout,\n        maxBuffer: 1024 * 1024 * 10, // 10MB buffer\n      });\n\n      const data = JSON.parse(stdout);\n\n      // Find video stream\n      const videoStream = data.streams.find((s: any) => s.codec_type === 'video');\n      if (!videoStream) {\n        throw new Error('No video stream found');\n      }\n\n      // Find audio stream\n      const audioStream = data.streams.find((s: any) => s.codec_type === 'audio');\n\n      // Extract metadata\n      const width = parseInt(videoStream.width, 10);\n      const height = parseInt(videoStream.height, 10);\n      const duration = parseFloat(data.format.duration);\n      const fileSize = parseInt(data.format.size, 10);\n\n      // Detect orientation\n      const orientation = this.detectOrientation(width, height);\n\n      // Detect quality\n      const quality = this.detectQuality(height);\n\n      return {\n        duration: isNaN(duration) ? null : Math.round(duration),\n        width: isNaN(width) ? null : width,\n        height: isNaN(height) ? null : height,\n        orientation,\n        quality,\n        hasAudio: !!audioStream,\n        fileSize: isNaN(fileSize) ? null : fileSize,\n        fileHash: null, // Can be computed separately if needed\n      };\n    } catch (error: any) {\n      throw new Error(`FFprobe extraction failed: ${error.message}`);\n    }\n  }\n\n  private detectOrientation(width: number, height: number): string {\n    if (isNaN(width) || isNaN(height)) return 'unknown';\n\n    const ratio = width / height;\n    if (ratio > 1.1) return 'landscape';\n    if (ratio < 0.9) return 'portrait';\n    return 'square';\n  }\n\n  private detectQuality(height: number): string {\n    if (isNaN(height)) return 'unknown';\n\n    if (height < 720) return 'SD';\n    if (height < 1080) return 'HD';\n    if (height < 2160) return 'FHD';\n    return 'UHD';\n  }\n}\n\nexport const ffprobeService = new FFprobeService();\n
"},{"location":"v2/features/media/upload/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/media/upload/#problem-upload-fails-with-file-too-large","title":"Problem: Upload Fails with \"File Too Large\"","text":"

Symptoms:

Solutions:

  1. Check file size:
# On macOS/Linux\nls -lh video.mp4\n# Should show size < 10GB\n\n# If larger, compress video first:\nffmpeg -i large-video.mp4 -vcodec h264 -acodec aac compressed.mp4\n
  1. Verify Fastify limit:
// api/src/media-server.ts\napp.register(multipart, {\n  limits: {\n    fileSize: 10 * 1024 * 1024 * 1024, // 10GB\n  },\n});\n
  1. Check nginx client_max_body_size:
# nginx/nginx.conf or nginx/conf.d/api.conf\nclient_max_body_size 10G;\n
  1. Increase timeout for large files:
# nginx/conf.d/api.conf\nserver {\n    location / {\n        proxy_pass http://localhost:4100;\n        proxy_read_timeout 600s;  # 10 minutes\n        proxy_send_timeout 600s;\n    }\n}\n
"},{"location":"v2/features/media/upload/#problem-ffprobe-metadata-extraction-fails","title":"Problem: FFprobe Metadata Extraction Fails","text":"

Symptoms:

Solutions:

  1. Check FFmpeg installed:
docker compose exec media-api which ffprobe\n# Should output: /usr/bin/ffprobe\n\ndocker compose exec media-api ffprobe -version\n# Should show FFmpeg version\n
  1. Install FFmpeg if missing:
# api/Dockerfile.media\nFROM node:20-alpine\n\n# Install FFmpeg\nRUN apk add --no-cache ffmpeg\n\n# ... rest of Dockerfile\n
# Rebuild container\ndocker compose build media-api\ndocker compose up -d media-api\n
  1. Test FFprobe manually:
# Run FFprobe on uploaded file\ndocker compose exec media-api ffprobe \\\n  -v quiet \\\n  -print_format json \\\n  -show_streams \\\n  -show_format \\\n  /media/local/inbox/test.mp4\n\n# Should output JSON with streams and format info\n
  1. Check video file not corrupt:
# Try playing video\ndocker compose exec media-api ffplay /media/local/inbox/test.mp4\n\n# Or copy to host and test\ndocker cp $(docker compose ps -q media-api):/media/local/inbox/test.mp4 ./\nvlc test.mp4\n
  1. Increase timeout for large files:
# .env\nFFPROBE_TIMEOUT=60000  # 60 seconds (from 30)\n
"},{"location":"v2/features/media/upload/#problem-upload-hangs-at-100","title":"Problem: Upload Hangs at 100%","text":"

Symptoms:

Solutions:

  1. Check nginx proxy timeout:
# nginx/conf.d/api.conf\nserver {\n    location / {\n        proxy_pass http://localhost:4100;\n        proxy_read_timeout 600s;  # 10 minutes for large uploads\n    }\n}\n
  1. Verify disk space available:
df -h /media/local/inbox\n# Should show available space > file size\n\n# Clear space if needed\ndocker compose exec media-api rm /media/local/inbox/*.mp4\n
  1. Check backend logs:
docker compose logs -f media-api | grep upload\n# Look for errors or timeouts\n
  1. Test with smaller file:
# Create 100MB test video\nffmpeg -f lavfi -i testsrc=duration=10:size=1920x1080:rate=30 -pix_fmt yuv420p test-100mb.mp4\n\n# Upload test file\n# If succeeds, issue likely large file timeout\n
"},{"location":"v2/features/media/upload/#problem-inbox-directory-not-writable","title":"Problem: Inbox Directory Not Writable","text":"

Symptoms:

Solutions:

  1. Check Docker volume mount:
# docker-compose.yml\nservices:\n  media-api:\n    volumes:\n      - /media/local/inbox:/media/local/inbox:rw  # MUST have :rw suffix\n
  1. Verify mount in running container:
docker compose exec media-api mount | grep inbox\n# Should show /media/local/inbox mounted as rw (read-write)\n
  1. Check directory permissions:
# On host machine\nls -la /media/local/inbox\n# Should show drwxrwxrwx or drwxr-xr-x\n\n# Fix permissions if needed\nsudo chmod 777 /media/local/inbox\n\n# Or set ownership to container user (usually node:node)\nsudo chown -R 1000:1000 /media/local/inbox\n
  1. Create directory if missing:
# On host\nsudo mkdir -p /media/local/inbox\nsudo chmod 777 /media/local/inbox\n\n# Restart container\ndocker compose restart media-api\n
  1. Test write access:
# Try writing test file from container\ndocker compose exec media-api sh -c 'echo \"test\" > /media/local/inbox/test.txt'\n\n# If fails, permissions issue\n# If succeeds, issue elsewhere\n
"},{"location":"v2/features/media/upload/#problem-invalid-file-type-error","title":"Problem: Invalid File Type Error","text":"

Symptoms:

Solutions:

  1. Check MIME type:
// Browser console\nconst file = document.querySelector('input[type=file]').files[0];\nconsole.log(file.type);\n// Should be video/mp4, video/quicktime, etc.\n
  1. Verify file extension:
# Rename file to ensure correct extension\nmv video.MP4 video.mp4  # Case-sensitive on Linux\n
  1. Add MIME type to allowed list:
// admin/src/components/media/UploadVideoModal.tsx\nconst isVideo = [\n  'video/mp4',\n  'video/quicktime',\n  'video/x-msvideo',\n  'video/x-matroska',\n  'video/webm',\n  'video/x-m4v',\n  'video/x-flv',\n  'video/mpeg',  // Add MPEG\n  'video/ogg',   // Add OGG\n].includes(file.type);\n
  1. Bypass frontend validation (testing only):
// Temporarily comment out beforeUpload validation\nbeforeUpload={() => false}\n
  1. Check backend extension validation:
// api/src/modules/media/routes/upload.routes.ts\nconst allowedExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv'];\n// Add more if needed\n
"},{"location":"v2/features/media/upload/#problem-batch-upload-only-uploads-first-file","title":"Problem: Batch Upload Only Uploads First File","text":"

Symptoms:

Solutions:

  1. Check sequential upload logic:
// admin/src/components/media/UploadVideoModal.tsx\n// Should use for loop, not forEach with async\nfor (const fileItem of fileList) {\n  await mediaApi.post(...);  // Await each upload\n}\n
  1. Verify batch endpoint:
# Use /api/media/upload/batch for multiple files\n# Not multiple calls to /api/media/upload/single\n
  1. Check Fastify file limit:
// api/src/media-server.ts\napp.register(multipart, {\n  limits: {\n    files: 10,  // Max 10 files per request\n  },\n});\n
  1. Frontend: prevent early unmount:
// Don't close modal while uploading\n<Modal\n  closable={!uploading}\n  maskClosable={!uploading}\n  ...\n/>\n
"},{"location":"v2/features/media/upload/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/media/upload/#upload-speed","title":"Upload Speed","text":"

Factors:

Typical Speeds:

File Size Upload Time (100 Mbps) Upload Time (1 Gbps) 100 MB ~10 seconds ~1 second 1 GB ~1.5 minutes ~10 seconds 5 GB ~7 minutes ~50 seconds 10 GB ~14 minutes ~1.5 minutes

Optimization:

  1. Disable nginx buffering:
# nginx/conf.d/api.conf\nlocation /api/media/upload {\n    proxy_pass http://localhost:4100;\n    proxy_request_buffering off;  # Stream directly to backend\n    client_max_body_size 10G;\n}\n
  1. Use faster disk:

Mount /media/local/inbox on SSD instead of HDD.

  1. Increase network MTU:
# Increase Docker network MTU\ndocker network create --opt com.docker.network.driver.mtu=9000 changemaker-lite\n
"},{"location":"v2/features/media/upload/#ffprobe-extraction-time","title":"FFprobe Extraction Time","text":"

Benchmarks:

Video Size Resolution Extraction Time 50 MB 720p ~50-100ms 200 MB 1080p ~100-200ms 1 GB 1080p ~200-400ms 5 GB 4K ~500ms-1s

Optimization:

FFprobe only reads video metadata (not entire file), so extraction time scales sub-linearly with file size.

For very large files (10GB+), consider deferring extraction to job queue:

// Upload endpoint returns immediately\nconst video = await db.insert(videos).values({ ... }).returning();\n\n// Queue FFprobe job\nawait jobQueue.add('extract-metadata', { videoId: video.id });\n\nreply.send({ id: video.id, status: 'pending-metadata' });\n
"},{"location":"v2/features/media/upload/#streaming-vs-buffering","title":"Streaming vs Buffering","text":"

Memory Usage Comparison:

Upload Method Memory Usage (10GB file) Streaming (current) ~10 MB Buffering (alternative) ~10 GB

Why Streaming:

Tradeoff:

Streaming writes directly to disk, so failed uploads leave partial files in /inbox. Cleanup script required:

# Cron job to clean incomplete uploads (files with 0 size)\nfind /media/local/inbox -type f -size 0 -mtime +1 -delete\n
"},{"location":"v2/features/media/upload/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/features/media/upload/#admin-only-access","title":"Admin-Only Access","text":"

All upload endpoints require SUPER_ADMIN role:

// api/src/modules/media/routes/upload.routes.ts\napp.post('/api/media/upload/single', {\n  preHandler: [requireRole('SUPER_ADMIN')],\n}, async (req, reply) => {\n  // ...\n});\n

Regular users, volunteers, and public cannot upload videos.

"},{"location":"v2/features/media/upload/#file-extension-validation","title":"File Extension Validation","text":"

Backend enforces strict whitelist:

const allowedExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv'];\n\nif (!allowedExtensions.includes(ext)) {\n  return reply.code(400).send({ error: 'Invalid file type' });\n}\n

No executable extensions allowed:

"},{"location":"v2/features/media/upload/#path-traversal-prevention","title":"Path Traversal Prevention","text":"

UUID filenames prevent directory traversal:

// User-supplied filename: ../../etc/passwd.mp4\n// Actual filename: 660e8400-e29b-41d4-a716-446655440000.mp4\n\nconst uuid = randomUUID();\nconst filename = `${uuid}${ext}`;  // No user input in filename\n

Original filename preserved in database:

originalFilename: data.filename,  // Stored for reference, not used for filepath\n
"},{"location":"v2/features/media/upload/#virus-scanning-future","title":"Virus Scanning (Future)","text":"

Recommended Integration:

// api/src/modules/media/services/virus-scan.service.ts\nimport { exec } from 'child_process';\n\nclass VirusScanService {\n  async scan(filePath: string): Promise<{ clean: boolean; threat?: string }> {\n    // Use ClamAV\n    const { stdout } = await execAsync(`clamscan --no-summary ${filePath}`);\n\n    if (stdout.includes('FOUND')) {\n      return { clean: false, threat: stdout };\n    }\n\n    return { clean: true };\n  }\n}\n\n// In upload route:\nconst scanResult = await virusScanService.scan(absolutePath);\nif (!scanResult.clean) {\n  await fs.unlink(absolutePath);  // Delete infected file\n  return reply.code(400).send({ error: 'File contains malware' });\n}\n
"},{"location":"v2/features/media/upload/#rate-limiting","title":"Rate Limiting","text":"

Upload endpoint has stricter rate limits:

// api/src/modules/media/routes/upload.routes.ts\nimport rateLimit from '@fastify/rate-limit';\n\napp.register(rateLimit, {\n  max: 10,        // 10 uploads\n  timeWindow: '1 hour',\n});\n

Prevents abuse (uploading hundreds of large files).

"},{"location":"v2/features/media/upload/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/media/upload/#backend-documentation","title":"Backend Documentation","text":""},{"location":"v2/features/media/upload/#frontend-documentation","title":"Frontend Documentation","text":""},{"location":"v2/features/media/upload/#feature-documentation","title":"Feature Documentation","text":""},{"location":"v2/features/media/upload/#deployment-documentation","title":"Deployment Documentation","text":""},{"location":"v2/features/media/upload/#next-steps","title":"Next Steps","text":"

After mastering video upload:

  1. Move Videos \u2014 Learn how to move uploaded videos from /inbox to target directories
  2. Thumbnail Generation \u2014 Create thumbnails for video previews
  3. Encoding Jobs \u2014 Queue re-encoding jobs for web-optimized playback
  4. Public Sharing \u2014 Share videos in public gallery (see public-gallery.md)

Hands-On Practice:

# 1. Create test video (FFmpeg)\nffmpeg -f lavfi -i testsrc=duration=30:size=1920x1080:rate=30 -pix_fmt yuv420p test-video.mp4\n\n# 2. Upload via curl\ncurl -X POST http://localhost:4100/api/media/upload/single \\\n  -H \"Authorization: Bearer YOUR_ADMIN_TOKEN\" \\\n  -F \"video=@test-video.mp4\" \\\n  -F \"producer=Test Studio\" \\\n  -F \"title=Test Video\"\n\n# 3. Verify in database\ndocker compose exec v2-postgres psql -U changemaker -d v2_changemaker \\\n  -c \"SELECT id, filename, duration_seconds, quality FROM videos ORDER BY created_at DESC LIMIT 1;\"\n\n# 4. Check file on disk\ndocker compose exec media-api ls -lh /media/local/inbox/\n

Last Updated: 2026-02-13 Version: V2.0 Maintainer: Changemaker Lite Team

"},{"location":"v2/features/media/video-library/","title":"Video Library Management","text":""},{"location":"v2/features/media/video-library/#overview","title":"Overview","text":"

The Video Library system provides comprehensive video asset management through a dedicated Fastify microservice running on port 4100, separate from the main Express API. This dual API architecture allows the media system to operate independently while sharing the same PostgreSQL database.

Key Features:

Access Control:

Technology Stack:

"},{"location":"v2/features/media/video-library/#architecture","title":"Architecture","text":"

The Media API operates as an independent microservice while maintaining data consistency through shared database access:

flowchart TB\n    subgraph \"Client Layer\"\n        Admin[Admin GUI :3000]\n        Public[Public Users]\n    end\n\n    subgraph \"API Layer\"\n        Express[Express API :4000<br/>Prisma ORM]\n        Fastify[Fastify Media API :4100<br/>Drizzle ORM]\n    end\n\n    subgraph \"Data Layer\"\n        DB[(PostgreSQL 16<br/>v2_changemaker)]\n        FS[/media/local/library/<br/>Video Files]\n    end\n\n    subgraph \"Processing\"\n        FFprobe[FFprobe Service<br/>Metadata Extraction]\n    end\n\n    Admin -->|Media Requests| Fastify\n    Admin -->|Other Requests| Express\n    Public -->|View Videos| Fastify\n\n    Fastify -->|Drizzle Queries| DB\n    Express -->|Prisma Queries| DB\n\n    Fastify -->|Read/Write| FS\n    Fastify -->|Extract Metadata| FFprobe\n    FFprobe -->|Analyze| FS\n\n    style Fastify fill:#e74c3c\n    style Express fill:#3498db\n    style DB fill:#2ecc71\n    style FS fill:#f39c12

Architecture Highlights:

  1. Port Separation \u2014 Media API on 4100, Main API on 4000
  2. ORM Independence \u2014 Drizzle for media, Prisma for everything else
  3. Shared Database \u2014 Both APIs access same PostgreSQL instance
  4. File System Access \u2014 Media API has direct volume mount to /media/local/library
  5. Nginx Routing \u2014 media.cmlite.org routes to port 4100

Why Dual API?

The media system was added after V2 launch as a self-contained enhancement. Keeping it as a separate Fastify microservice:

"},{"location":"v2/features/media/video-library/#database-model-drizzle","title":"Database Model (Drizzle)","text":""},{"location":"v2/features/media/video-library/#videos-table-schema","title":"Videos Table Schema","text":"
// api/src/modules/media/db/schema.ts\nimport { pgTable, uuid, text, integer, timestamp, boolean, jsonb } from 'drizzle-orm/pg-core';\n\nexport const videos = pgTable('videos', {\n  id: uuid('id').primaryKey().defaultRandom(),\n\n  // File Information\n  path: text('path').notNull().unique(), // Relative path from library root\n  filename: text('filename').notNull(),\n  originalFilename: text('original_filename'), // User-uploaded filename\n  directoryType: text('directory_type').notNull(), // studios|gifs|private|inbox|curated|playback|compilations|videos|highlights\n\n  // Metadata\n  producer: text('producer'),\n  creator: text('creator'),\n  title: text('title'),\n  tags: jsonb('tags').$type<string[]>().default([]),\n\n  // Video Properties\n  durationSeconds: integer('duration_seconds'),\n  quality: text('quality'), // SD|HD|FHD|UHD\n  orientation: text('orientation'), // portrait|landscape|square\n  hasAudio: boolean('has_audio').default(false),\n  width: integer('width'),\n  height: integer('height'),\n\n  // File Details\n  fileSize: integer('file_size'), // Bytes\n  fileHash: text('file_hash'), // SHA-256 for duplicate detection\n\n  // Validation\n  isValid: boolean('is_valid').default(true),\n  lastValidated: timestamp('last_validated'),\n  standardizedAt: timestamp('standardized_at'), // When file was moved to standard location\n\n  // Thumbnail\n  thumbnailPath: text('thumbnail_path'),\n\n  // Public Sharing\n  publicViewCount: integer('public_view_count').default(0),\n  publicUpvoteCount: integer('public_upvote_count').default(0),\n  movedFromPublicAt: timestamp('moved_from_public_at'), // When video was unlocked from public\n\n  // Timestamps\n  createdAt: timestamp('created_at').defaultNow(),\n  updatedAt: timestamp('updated_at').defaultNow(),\n});\n
"},{"location":"v2/features/media/video-library/#directory-types-enum","title":"Directory Types Enum","text":"Directory Type Purpose Public Eligible studios Studio-organized content \u2705 gifs Short looping videos \u2705 private Private/unreleased content \u274c inbox Upload staging area \u274c curated Hand-picked highlights \u2705 playback Playback-optimized encodes \u2705 compilations Multi-video compilations \u2705 videos General video library \u2705 highlights Auto-generated highlights \u2705"},{"location":"v2/features/media/video-library/#quality-classifications","title":"Quality Classifications","text":"Quality Height Range Typical Resolution SD < 720px 480p, 576p HD 720px - 1079px 720p FHD 1080px - 2159px 1080p UHD \u2265 2160px 4K, 8K"},{"location":"v2/features/media/video-library/#orientation-detection","title":"Orientation Detection","text":"
const detectOrientation = (width: number, height: number): string => {\n  const ratio = width / height;\n  if (ratio > 1.1) return 'landscape';\n  if (ratio < 0.9) return 'portrait';\n  return 'square';\n};\n
"},{"location":"v2/features/media/video-library/#api-endpoints","title":"API Endpoints","text":"

All endpoints require authentication with SUPER_ADMIN role unless marked as public.

"},{"location":"v2/features/media/video-library/#list-videos","title":"List Videos","text":"
GET /api/media/videos\n

Query Parameters:

Parameter Type Default Description page number 1 Page number for pagination limit number 20 Results per page (max 100) directoryType string - Filter by directory (studios, gifs, etc.) orientation string - Filter by orientation (portrait, landscape, square) producer string - Filter by producer (partial match) creator string - Filter by creator (partial match) quality string - Filter by quality (SD, HD, FHD, UHD) hasAudio boolean - Filter by audio presence isValid boolean true Filter by validation status search string - Search in title, producer, creator

Response:

{\n  \"data\": [\n    {\n      \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n      \"path\": \"videos/sample.mp4\",\n      \"filename\": \"sample.mp4\",\n      \"directoryType\": \"videos\",\n      \"producer\": \"Studio A\",\n      \"creator\": \"Director B\",\n      \"title\": \"Sample Video\",\n      \"durationSeconds\": 180,\n      \"quality\": \"FHD\",\n      \"orientation\": \"landscape\",\n      \"hasAudio\": true,\n      \"width\": 1920,\n      \"height\": 1080,\n      \"fileSize\": 52428800,\n      \"isValid\": true,\n      \"createdAt\": \"2026-02-10T12:00:00Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 156,\n    \"totalPages\": 8\n  }\n}\n
"},{"location":"v2/features/media/video-library/#get-video-details","title":"Get Video Details","text":"
GET /api/media/videos/:id\n

Response:

{\n  \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"path\": \"videos/sample.mp4\",\n  \"filename\": \"sample.mp4\",\n  \"originalFilename\": \"my-video.mp4\",\n  \"directoryType\": \"videos\",\n  \"producer\": \"Studio A\",\n  \"creator\": \"Director B\",\n  \"title\": \"Sample Video\",\n  \"tags\": [\"action\", \"sports\", \"highlight\"],\n  \"durationSeconds\": 180,\n  \"quality\": \"FHD\",\n  \"orientation\": \"landscape\",\n  \"hasAudio\": true,\n  \"width\": 1920,\n  \"height\": 1080,\n  \"fileSize\": 52428800,\n  \"fileHash\": \"a3d2f1e8b9c7...\",\n  \"isValid\": true,\n  \"lastValidated\": \"2026-02-10T12:00:00Z\",\n  \"thumbnailPath\": \"thumbnails/550e8400.jpg\",\n  \"publicViewCount\": 1250,\n  \"publicUpvoteCount\": 85,\n  \"createdAt\": \"2026-02-10T12:00:00Z\",\n  \"updatedAt\": \"2026-02-10T12:00:00Z\"\n}\n
"},{"location":"v2/features/media/video-library/#create-video-record","title":"Create Video Record","text":"
POST /api/media/videos\n

Request Body:

{\n  \"path\": \"videos/new-video.mp4\",\n  \"filename\": \"new-video.mp4\",\n  \"directoryType\": \"videos\",\n  \"producer\": \"Studio A\",\n  \"creator\": \"Director B\",\n  \"title\": \"New Video\",\n  \"tags\": [\"action\", \"sports\"]\n}\n

Notes:

Response:

{\n  \"id\": \"660e8400-e29b-41d4-a716-446655440000\",\n  \"path\": \"videos/new-video.mp4\",\n  \"filename\": \"new-video.mp4\",\n  \"directoryType\": \"videos\",\n  \"isValid\": true,\n  \"createdAt\": \"2026-02-13T10:30:00Z\"\n}\n
"},{"location":"v2/features/media/video-library/#update-video-metadata","title":"Update Video Metadata","text":"
PUT /api/media/videos/:id\n

Request Body:

{\n  \"producer\": \"Updated Studio\",\n  \"creator\": \"New Director\",\n  \"title\": \"Updated Title\",\n  \"tags\": [\"updated\", \"tags\"]\n}\n

Updatable Fields:

Response:

{\n  \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"producer\": \"Updated Studio\",\n  \"creator\": \"New Director\",\n  \"title\": \"Updated Title\",\n  \"tags\": [\"updated\", \"tags\"],\n  \"updatedAt\": \"2026-02-13T10:35:00Z\"\n}\n
"},{"location":"v2/features/media/video-library/#delete-video","title":"Delete Video","text":"
DELETE /api/media/videos/:id\n

Behavior:

Response:

{\n  \"success\": true,\n  \"message\": \"Video marked as invalid\"\n}\n
"},{"location":"v2/features/media/video-library/#scan-directory","title":"Scan Directory","text":"
POST /api/media/videos/scan\n

Request Body:

{\n  \"directoryType\": \"videos\",\n  \"skipExisting\": true\n}\n

Parameters:

Field Type Required Description directoryType string \u2705 Directory to scan (videos, studios, etc.) skipExisting boolean - Skip files already in database (default: true)

Process:

  1. Reads filesystem directory /media/local/library/{directoryType}/
  2. Filters for video extensions (.mp4, .mov, .avi, .mkv, .webm, .m4v, .flv)
  3. Checks each file against database (by path)
  4. Creates records for new files
  5. Runs FFprobe metadata extraction on new records

Response:

{\n  \"scanned\": 45,\n  \"created\": 12,\n  \"skipped\": 33,\n  \"failed\": 0,\n  \"errors\": []\n}\n
"},{"location":"v2/features/media/video-library/#validate-video","title":"Validate Video","text":"
POST /api/media/videos/:id/validate\n

Purpose:

Response:

{\n  \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"isValid\": true,\n  \"lastValidated\": \"2026-02-13T10:40:00Z\",\n  \"metadata\": {\n    \"durationSeconds\": 180,\n    \"width\": 1920,\n    \"height\": 1080,\n    \"quality\": \"FHD\",\n    \"orientation\": \"landscape\",\n    \"hasAudio\": true\n  }\n}\n
"},{"location":"v2/features/media/video-library/#configuration","title":"Configuration","text":""},{"location":"v2/features/media/video-library/#environment-variables","title":"Environment Variables","text":"
# Media API Server\nMEDIA_API_PORT=4100\nMEDIA_API_HOST=0.0.0.0\n\n# File Paths\nMEDIA_LIBRARY_PATH=/media/local/library\nMEDIA_INBOX_PATH=/media/local/inbox\n\n# Feature Flags\nENABLE_MEDIA_FEATURES=true\n\n# Database (shared with main API)\nDATABASE_URL=postgresql://user:pass@v2-postgres:5432/v2_changemaker\n\n# FFprobe\nFFPROBE_TIMEOUT=30000  # milliseconds\nFFPROBE_PATH=/usr/bin/ffprobe  # Auto-detected if not set\n
"},{"location":"v2/features/media/video-library/#docker-volume-mounts","title":"Docker Volume Mounts","text":"
# docker-compose.yml\nservices:\n  media-api:\n    volumes:\n      - /media/local/library:/media/local/library:ro  # Read-only library\n      - /media/local/inbox:/media/local/inbox:rw      # Read-write inbox\n

Important: Inbox requires :rw (read-write) for uploads. Library can be :ro (read-only) for security.

"},{"location":"v2/features/media/video-library/#site-settings","title":"Site Settings","text":"

The media system respects the global ENABLE_MEDIA_FEATURES flag in Site Settings:

SELECT * FROM settings WHERE key = 'ENABLE_MEDIA_FEATURES';\n

When disabled:

"},{"location":"v2/features/media/video-library/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/media/video-library/#viewing-the-video-library","title":"Viewing the Video Library","text":"
  1. Navigate to Media \u2192 Library in admin sidebar
  2. Table displays all videos with:
  3. Thumbnail preview
  4. Title, producer, creator
  5. Duration, quality, orientation
  6. Directory type
  7. File size
  8. Created date
  9. Use filters at top:
  10. Directory Type dropdown
  11. Orientation radio buttons (All / Portrait / Landscape / Square)
  12. Quality checkboxes (SD, HD, FHD, UHD)
  13. Search input (searches title, producer, creator)
"},{"location":"v2/features/media/video-library/#scanning-a-directory","title":"Scanning a Directory","text":"

When to Use:

Steps:

  1. Click \"Scan Directory\" button in Library page toolbar
  2. Select directory type from dropdown
  3. Toggle \"Skip Existing\" (recommended for large libraries)
  4. Click \"Start Scan\"
  5. Progress modal shows:
  6. Files scanned
  7. New records created
  8. Skipped (already in DB)
  9. Failed (with error messages)
  10. Click \"Close\" when complete
  11. Table refreshes with new videos

Example Output:

Scanning /media/local/library/videos...\nFound 45 video files\n- Created 12 new records\n- Skipped 33 existing records\n- Failed 0 files\nScan complete in 8.3 seconds\n
"},{"location":"v2/features/media/video-library/#editing-video-metadata","title":"Editing Video Metadata","text":"
  1. Click pencil icon in video row
  2. Edit modal opens with fields:
  3. Producer \u2014 Studio or production company
  4. Creator \u2014 Director or primary creator
  5. Title \u2014 Display title
  6. Tags \u2014 Comma-separated tags (auto-suggests existing tags)
  7. Click \"Save\" to update
  8. Metadata changes immediately visible in table

Bulk Editing:

  1. Select multiple videos using checkboxes
  2. Click \"Bulk Edit\" button
  3. Set common fields (producer, tags, etc.)
  4. Click \"Apply to Selected\"
"},{"location":"v2/features/media/video-library/#validating-videos","title":"Validating Videos","text":"

Purpose: Refresh metadata and verify file integrity

Steps:

  1. Click \"Validate\" button in video row (or Actions dropdown)
  2. FFprobe re-analyzes video file
  3. Database updates with fresh metadata:
  4. Duration (may have changed if file was re-encoded)
  5. Dimensions
  6. Audio detection
  7. File size and hash
  8. lastValidated timestamp updates
  9. If file missing or corrupt, isValid set to false

Bulk Validation:

  1. Select multiple videos
  2. Click \"Validate Selected\"
  3. Progress modal shows validation results
  4. Failed validations highlighted in red
"},{"location":"v2/features/media/video-library/#deleting-videos","title":"Deleting Videos","text":"

Soft Delete (Default):

  1. Click trash icon in video row
  2. Confirm deletion dialog
  3. Video marked isValid = false
  4. Video disappears from default view
  5. File remains on filesystem
  6. Record preserved in database

Viewing Deleted Videos:

  1. Toggle \"Show Invalid\" filter
  2. Deleted videos appear with strikethrough
  3. Can restore by clicking \"Restore\" button

Hard Delete (Database Only):

  1. Filter for invalid videos
  2. Select video(s)
  3. Click \"Permanently Delete\"
  4. Removes database record
  5. File still on filesystem (manual cleanup required)

File System Cleanup:

Deleted video files must be manually removed from filesystem:

# SSH into media-api container\ndocker compose exec media-api sh\n\n# Navigate to library\ncd /media/local/library/videos\n\n# Remove specific file\nrm deleted-video.mp4\n\n# Or find and remove all invalid videos (BE CAREFUL)\n# (requires database query to get invalid file paths)\n
"},{"location":"v2/features/media/video-library/#directory-structure","title":"Directory Structure","text":"
/media/local/library/\n\u251c\u2500\u2500 studios/              # Studio-organized content\n\u2502   \u251c\u2500\u2500 studio-a/\n\u2502   \u2502   \u251c\u2500\u2500 video-001.mp4\n\u2502   \u2502   \u2514\u2500\u2500 video-002.mp4\n\u2502   \u2514\u2500\u2500 studio-b/\n\u2502       \u2514\u2500\u2500 video-003.mp4\n\u2502\n\u251c\u2500\u2500 gifs/                 # Short looping videos\n\u2502   \u251c\u2500\u2500 loop-001.mp4\n\u2502   \u2514\u2500\u2500 loop-002.webm\n\u2502\n\u251c\u2500\u2500 private/              # Private/unreleased content\n\u2502   \u2514\u2500\u2500 unreleased.mp4\n\u2502\n\u251c\u2500\u2500 inbox/                # Upload staging area (READ-WRITE)\n\u2502   \u251c\u2500\u2500 uuid-123.mp4      # Temp uploads\n\u2502   \u2514\u2500\u2500 uuid-456.mov\n\u2502\n\u251c\u2500\u2500 curated/              # Hand-picked highlights\n\u2502   \u251c\u2500\u2500 best-of-2025.mp4\n\u2502   \u2514\u2500\u2500 top-plays.mp4\n\u2502\n\u251c\u2500\u2500 playback/             # Playback-optimized encodes\n\u2502   \u251c\u2500\u2500 streaming-001.mp4\n\u2502   \u2514\u2500\u2500 streaming-002.mp4\n\u2502\n\u251c\u2500\u2500 compilations/         # Multi-video compilations\n\u2502   \u251c\u2500\u2500 compilation-001.mp4\n\u2502   \u2514\u2500\u2500 mega-compilation.mp4\n\u2502\n\u251c\u2500\u2500 videos/               # General video library\n\u2502   \u251c\u2500\u2500 video-001.mp4\n\u2502   \u251c\u2500\u2500 video-002.mp4\n\u2502   \u2514\u2500\u2500 ... (thousands of videos)\n\u2502\n\u2514\u2500\u2500 highlights/           # Auto-generated highlights\n    \u251c\u2500\u2500 highlight-001.mp4\n    \u2514\u2500\u2500 highlight-002.mp4\n

Directory Guidelines:

"},{"location":"v2/features/media/video-library/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/media/video-library/#list-videos-with-filters-fastify-route","title":"List Videos with Filters (Fastify Route)","text":"
// api/src/modules/media/routes/videos.routes.ts\nimport { FastifyInstance } from 'fastify';\nimport { eq, and, like, desc, sql } from 'drizzle-orm';\nimport { videos } from '@/modules/media/db/schema';\nimport { db } from '@/modules/media/db';\n\nexport default async function (app: FastifyInstance) {\n  app.get('/api/media/videos', async (req, reply) => {\n    const {\n      page = 1,\n      limit = 20,\n      directoryType,\n      orientation,\n      producer,\n      creator,\n      quality,\n      hasAudio,\n      isValid = true,\n      search,\n    } = req.query as any;\n\n    // Build filters\n    const filters = [];\n\n    if (directoryType) {\n      filters.push(eq(videos.directoryType, directoryType));\n    }\n\n    if (orientation) {\n      filters.push(eq(videos.orientation, orientation));\n    }\n\n    if (producer) {\n      filters.push(like(videos.producer, `%${producer}%`));\n    }\n\n    if (creator) {\n      filters.push(like(videos.creator, `%${creator}%`));\n    }\n\n    if (quality) {\n      filters.push(eq(videos.quality, quality));\n    }\n\n    if (typeof hasAudio === 'boolean') {\n      filters.push(eq(videos.hasAudio, hasAudio));\n    }\n\n    if (typeof isValid === 'boolean') {\n      filters.push(eq(videos.isValid, isValid));\n    }\n\n    if (search) {\n      filters.push(\n        sql`(\n          ${videos.title} ILIKE ${'%' + search + '%'} OR\n          ${videos.producer} ILIKE ${'%' + search + '%'} OR\n          ${videos.creator} ILIKE ${'%' + search + '%'}\n        )`\n      );\n    }\n\n    // Count total\n    const [{ count }] = await db\n      .select({ count: sql<number>`count(*)` })\n      .from(videos)\n      .where(and(...filters));\n\n    // Fetch paginated results\n    const results = await db\n      .select()\n      .from(videos)\n      .where(and(...filters))\n      .limit(Number(limit))\n      .offset((Number(page) - 1) * Number(limit))\n      .orderBy(desc(videos.createdAt));\n\n    reply.send({\n      data: results,\n      pagination: {\n        page: Number(page),\n        limit: Number(limit),\n        total: Number(count),\n        totalPages: Math.ceil(Number(count) / Number(limit)),\n      },\n    });\n  });\n}\n
"},{"location":"v2/features/media/video-library/#scan-directory-for-videos","title":"Scan Directory for Videos","text":"
// api/src/modules/media/routes/videos.routes.ts\nimport fs from 'fs/promises';\nimport path from 'path';\nimport { eq } from 'drizzle-orm';\nimport { videos } from '@/modules/media/db/schema';\nimport { ffprobeService } from '@/modules/media/services/ffprobe.service';\n\napp.post('/api/media/videos/scan', async (req, reply) => {\n  const { directoryType, skipExisting = true } = req.body as any;\n\n  if (!directoryType) {\n    return reply.code(400).send({ error: 'directoryType required' });\n  }\n\n  const dirPath = path.join(process.env.MEDIA_LIBRARY_PATH!, directoryType);\n\n  try {\n    // Check directory exists\n    await fs.access(dirPath);\n  } catch {\n    return reply.code(400).send({ error: `Directory not found: ${directoryType}` });\n  }\n\n  // Read directory\n  const files = await fs.readdir(dirPath, { recursive: true });\n\n  // Filter for video files\n  const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv'];\n  const videoFiles = files.filter((f) =>\n    videoExtensions.some((ext) => f.toLowerCase().endsWith(ext))\n  );\n\n  const results = {\n    scanned: videoFiles.length,\n    created: 0,\n    skipped: 0,\n    failed: 0,\n    errors: [] as string[],\n  };\n\n  for (const filename of videoFiles) {\n    try {\n      const relativePath = path.join(directoryType, filename);\n\n      // Check if already exists\n      if (skipExisting) {\n        const existing = await db\n          .select()\n          .from(videos)\n          .where(eq(videos.path, relativePath))\n          .limit(1);\n\n        if (existing.length > 0) {\n          results.skipped++;\n          continue;\n        }\n      }\n\n      // Extract metadata\n      const fullPath = path.join(dirPath, filename);\n      const metadata = await ffprobeService.extract(fullPath);\n\n      // Create record\n      await db.insert(videos).values({\n        path: relativePath,\n        filename: path.basename(filename),\n        directoryType,\n        durationSeconds: metadata.duration,\n        width: metadata.width,\n        height: metadata.height,\n        orientation: metadata.orientation,\n        quality: metadata.quality,\n        hasAudio: metadata.hasAudio,\n        fileSize: metadata.fileSize,\n        isValid: true,\n      });\n\n      results.created++;\n    } catch (error: any) {\n      results.failed++;\n      results.errors.push(`${filename}: ${error.message}`);\n    }\n  }\n\n  reply.send(results);\n});\n
"},{"location":"v2/features/media/video-library/#validate-video-metadata","title":"Validate Video Metadata","text":"
// api/src/modules/media/routes/videos.routes.ts\nimport { eq } from 'drizzle-orm';\nimport { videos } from '@/modules/media/db/schema';\nimport { ffprobeService } from '@/modules/media/services/ffprobe.service';\n\napp.post('/api/media/videos/:id/validate', async (req, reply) => {\n  const { id } = req.params as { id: string };\n\n  // Fetch video record\n  const [video] = await db\n    .select()\n    .from(videos)\n    .where(eq(videos.id, id))\n    .limit(1);\n\n  if (!video) {\n    return reply.code(404).send({ error: 'Video not found' });\n  }\n\n  try {\n    // Build full file path\n    const fullPath = path.join(process.env.MEDIA_LIBRARY_PATH!, video.path);\n\n    // Extract fresh metadata\n    const metadata = await ffprobeService.extract(fullPath);\n\n    // Update database\n    const [updated] = await db\n      .update(videos)\n      .set({\n        durationSeconds: metadata.duration,\n        width: metadata.width,\n        height: metadata.height,\n        orientation: metadata.orientation,\n        quality: metadata.quality,\n        hasAudio: metadata.hasAudio,\n        fileSize: metadata.fileSize,\n        fileHash: metadata.fileHash,\n        isValid: true,\n        lastValidated: new Date(),\n        updatedAt: new Date(),\n      })\n      .where(eq(videos.id, id))\n      .returning();\n\n    reply.send({\n      id: updated.id,\n      isValid: updated.isValid,\n      lastValidated: updated.lastValidated,\n      metadata: {\n        durationSeconds: updated.durationSeconds,\n        width: updated.width,\n        height: updated.height,\n        quality: updated.quality,\n        orientation: updated.orientation,\n        hasAudio: updated.hasAudio,\n      },\n    });\n  } catch (error: any) {\n    // Mark as invalid if validation fails\n    await db\n      .update(videos)\n      .set({\n        isValid: false,\n        lastValidated: new Date(),\n        updatedAt: new Date(),\n      })\n      .where(eq(videos.id, id));\n\n    reply.code(500).send({\n      error: 'Validation failed',\n      message: error.message,\n      isValid: false,\n    });\n  }\n});\n
"},{"location":"v2/features/media/video-library/#frontend-library-page-table","title":"Frontend: Library Page Table","text":"
// admin/src/pages/media/LibraryPage.tsx\nimport { Table, Button, Select, Input, Tag, Space } from 'antd';\nimport { useEffect, useState } from 'react';\nimport { mediaApi } from '@/lib/media-api';\n\nexport default function LibraryPage() {\n  const [videos, setVideos] = useState([]);\n  const [loading, setLoading] = useState(false);\n  const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });\n  const [filters, setFilters] = useState({\n    directoryType: undefined,\n    orientation: undefined,\n    search: '',\n  });\n\n  const fetchVideos = async () => {\n    setLoading(true);\n    try {\n      const { data } = await mediaApi.get('/api/media/videos', {\n        params: {\n          page: pagination.page,\n          limit: pagination.limit,\n          ...filters,\n        },\n      });\n      setVideos(data.data);\n      setPagination((prev) => ({ ...prev, total: data.pagination.total }));\n    } catch (error) {\n      console.error('Failed to fetch videos:', error);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  useEffect(() => {\n    fetchVideos();\n  }, [pagination.page, filters]);\n\n  const columns = [\n    {\n      title: 'Preview',\n      dataIndex: 'thumbnailPath',\n      width: 100,\n      render: (path: string) => (\n        <img\n          src={path || '/placeholder.jpg'}\n          alt=\"Thumbnail\"\n          style={{ width: 80, height: 60, objectFit: 'cover' }}\n        />\n      ),\n    },\n    {\n      title: 'Title',\n      dataIndex: 'title',\n      render: (text: string, record: any) => (\n        <div>\n          <div style={{ fontWeight: 600 }}>{text || record.filename}</div>\n          <div style={{ fontSize: 12, color: '#888' }}>\n            {record.producer} \u2022 {record.creator}\n          </div>\n        </div>\n      ),\n    },\n    {\n      title: 'Duration',\n      dataIndex: 'durationSeconds',\n      width: 100,\n      render: (seconds: number) => {\n        const mins = Math.floor(seconds / 60);\n        const secs = seconds % 60;\n        return `${mins}:${secs.toString().padStart(2, '0')}`;\n      },\n    },\n    {\n      title: 'Quality',\n      dataIndex: 'quality',\n      width: 80,\n      render: (quality: string) => {\n        const colors: Record<string, string> = {\n          SD: 'default',\n          HD: 'blue',\n          FHD: 'green',\n          UHD: 'purple',\n        };\n        return <Tag color={colors[quality]}>{quality}</Tag>;\n      },\n    },\n    {\n      title: 'Orientation',\n      dataIndex: 'orientation',\n      width: 100,\n    },\n    {\n      title: 'Directory',\n      dataIndex: 'directoryType',\n      width: 120,\n    },\n    {\n      title: 'Actions',\n      width: 150,\n      render: (_: any, record: any) => (\n        <Space>\n          <Button size=\"small\" onClick={() => handleEdit(record.id)}>\n            Edit\n          </Button>\n          <Button size=\"small\" onClick={() => handleValidate(record.id)}>\n            Validate\n          </Button>\n          <Button size=\"small\" danger onClick={() => handleDelete(record.id)}>\n            Delete\n          </Button>\n        </Space>\n      ),\n    },\n  ];\n\n  return (\n    <div>\n      <Space style={{ marginBottom: 16 }}>\n        <Select\n          placeholder=\"Directory Type\"\n          style={{ width: 200 }}\n          onChange={(value) => setFilters({ ...filters, directoryType: value })}\n          allowClear\n        >\n          <Select.Option value=\"videos\">Videos</Select.Option>\n          <Select.Option value=\"studios\">Studios</Select.Option>\n          <Select.Option value=\"gifs\">GIFs</Select.Option>\n          <Select.Option value=\"curated\">Curated</Select.Option>\n        </Select>\n\n        <Input.Search\n          placeholder=\"Search title, producer, creator\"\n          style={{ width: 300 }}\n          onSearch={(value) => setFilters({ ...filters, search: value })}\n          allowClear\n        />\n\n        <Button type=\"primary\" onClick={handleScanDirectory}>\n          Scan Directory\n        </Button>\n      </Space>\n\n      <Table\n        columns={columns}\n        dataSource={videos}\n        loading={loading}\n        rowKey=\"id\"\n        pagination={{\n          current: pagination.page,\n          pageSize: pagination.limit,\n          total: pagination.total,\n          onChange: (page) => setPagination({ ...pagination, page }),\n        }}\n      />\n    </div>\n  );\n}\n
"},{"location":"v2/features/media/video-library/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/media/video-library/#problem-media-api-not-accessible","title":"Problem: Media API Not Accessible","text":"

Symptoms:

Solutions:

  1. Check Fastify server running:
docker compose ps media-api\n# Should show \"Up\" status\n\ndocker compose logs media-api\n# Look for \"Fastify server listening on port 4100\"\n
  1. Verify port 4100 not in use:
lsof -i :4100\n# Should show only media-api container\n\n# If another process using port, stop it or change MEDIA_API_PORT in .env\n
  1. Check nginx proxy configuration:
# nginx/conf.d/api.conf\n# Media API block must come BEFORE general API block\n\nserver {\n    listen 80;\n    server_name media.cmlite.org;\n\n    location / {\n        proxy_pass http://localhost:4100;\n        proxy_http_version 1.1;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection 'upgrade';\n        proxy_set_header Host $host;\n        proxy_cache_bypass $http_upgrade;\n    }\n}\n
  1. Test direct API access:
# From host machine\ncurl http://localhost:4100/api/media/videos\n\n# From inside container\ndocker compose exec media-api curl http://localhost:4100/api/media/videos\n
  1. Check Docker networking:
docker network inspect changemaker-lite\n# Verify media-api container connected\n
"},{"location":"v2/features/media/video-library/#problem-scan-finds-no-videos","title":"Problem: Scan Finds No Videos","text":"

Symptoms:

Solutions:

  1. Verify MEDIA_LIBRARY_PATH correct:
# Check environment variable\ndocker compose exec media-api printenv MEDIA_LIBRARY_PATH\n# Should output: /media/local/library\n\n# List directory contents\ndocker compose exec media-api ls -la /media/local/library/videos\n# Should show video files\n
  1. Check directory exists:
# Create missing directory\ndocker compose exec media-api mkdir -p /media/local/library/videos\n\n# Copy test videos\ndocker cp test.mp4 $(docker compose ps -q media-api):/media/local/library/videos/\n
  1. Verify Docker volume mounted:
# docker-compose.yml\nservices:\n  media-api:\n    volumes:\n      - /media/local/library:/media/local/library:ro  # Check path correct\n
# Inspect volume mounts\ndocker compose config | grep -A 5 media-api\n
  1. Check file extensions supported:

Only these extensions scanned:

Rename files if using other extensions:

# Rename .MP4 to .mp4 (case-sensitive)\ndocker compose exec media-api sh -c 'cd /media/local/library/videos && rename \"s/.MP4$/.mp4/\" *.MP4'\n
  1. Check file permissions:
# Verify readable by container user\ndocker compose exec media-api ls -la /media/local/library/videos\n\n# Fix permissions if needed (on host)\nsudo chmod -R 755 /media/local/library\n
"},{"location":"v2/features/media/video-library/#problem-ffprobe-validation-fails","title":"Problem: FFprobe Validation Fails","text":"

Symptoms:

Solutions:

  1. Check FFmpeg installed in container:
# Verify FFprobe available\ndocker compose exec media-api which ffprobe\n# Should output: /usr/bin/ffprobe\n\ndocker compose exec media-api ffprobe -version\n# Should show FFmpeg version info\n
  1. Install FFmpeg if missing:
# api/Dockerfile.media\nFROM node:20-alpine\n\n# Install FFmpeg (both dev and production stages)\nRUN apk add --no-cache ffmpeg\n\n# ... rest of Dockerfile\n
# Rebuild container\ndocker compose build media-api\ndocker compose up -d media-api\n
  1. Test FFprobe directly on video:
# Run FFprobe manually\ndocker compose exec media-api ffprobe -v quiet -print_format json -show_streams -show_format /media/local/library/videos/test.mp4\n\n# If this fails, video file corrupt or unsupported\n
  1. Check timeout not exceeded:

Default timeout: 30 seconds

# For very large files (>5GB), increase timeout\n# api/src/modules/media/services/ffprobe.service.ts\nconst FFPROBE_TIMEOUT = 60000; // 60 seconds\n
  1. Verify video file not corrupt:
# Test playback\ndocker compose exec media-api ffplay /media/local/library/videos/test.mp4\n\n# Or copy to host and test in VLC\ndocker cp $(docker compose ps -q media-api):/media/local/library/videos/test.mp4 ./test.mp4\nvlc test.mp4\n
  1. Check for special characters in filename:
# Rename files with spaces or special chars\ndocker compose exec media-api sh -c 'cd /media/local/library/videos && rename \"s/ /_/g\" *.mp4'\n
"},{"location":"v2/features/media/video-library/#problem-drizzle-schema-changes-not-applied","title":"Problem: Drizzle Schema Changes Not Applied","text":"

Symptoms:

Solutions:

  1. Push schema changes:
# Drizzle uses push (not migrations)\ncd api\nnpx drizzle-kit push\n\n# Confirm changes\n
  1. Verify connection:
# Check DATABASE_URL correct\ndocker compose exec media-api printenv DATABASE_URL\n\n# Test connection\ndocker compose exec media-api npx drizzle-kit studio\n# Opens DB browser on http://localhost:4983\n
  1. Compare with Prisma migrations:

Media tables exist in same database as Prisma tables. If conflict:

# Check both schemas\nnpx prisma db pull  # Prisma introspection\nnpx drizzle-kit introspect  # Drizzle introspection\n\n# Resolve conflicts manually\n
"},{"location":"v2/features/media/video-library/#problem-large-library-performance","title":"Problem: Large Library Performance","text":"

Symptoms:

Solutions:

  1. Add database indexes:
-- Index for common filters\nCREATE INDEX idx_videos_directory_type ON videos(directory_type);\nCREATE INDEX idx_videos_orientation ON videos(orientation);\nCREATE INDEX idx_videos_quality ON videos(quality);\nCREATE INDEX idx_videos_is_valid ON videos(is_valid);\nCREATE INDEX idx_videos_created_at ON videos(created_at DESC);\n\n-- Composite index for filtered queries\nCREATE INDEX idx_videos_filters ON videos(directory_type, is_valid, created_at DESC);\n\n-- Full-text search index\nCREATE INDEX idx_videos_search ON videos USING gin(to_tsvector('english', coalesce(title, '') || ' ' || coalesce(producer, '') || ' ' || coalesce(creator, '')));\n
  1. Reduce page size:
// admin/src/pages/media/LibraryPage.tsx\nconst [pagination, setPagination] = useState({ page: 1, limit: 10, total: 0 });\n// Reduced from 20 to 10\n
  1. Enable query caching:
// api/src/modules/media/routes/videos.routes.ts\nimport { redisClient } from '@/config/redis';\n\napp.get('/api/media/videos', async (req, reply) => {\n  const cacheKey = `videos:list:${JSON.stringify(req.query)}`;\n\n  // Check cache\n  const cached = await redisClient.get(cacheKey);\n  if (cached) {\n    return reply.send(JSON.parse(cached));\n  }\n\n  // Fetch from database\n  const results = await db.select()...;\n\n  // Cache for 5 minutes\n  await redisClient.setex(cacheKey, 300, JSON.stringify(results));\n\n  reply.send(results);\n});\n
  1. Use virtual scrolling:
// Replace Ant Design Table with react-window for large datasets\nimport { FixedSizeList } from 'react-window';\n
"},{"location":"v2/features/media/video-library/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/media/video-library/#directory-scans","title":"Directory Scans","text":"

Scaling Factors:

Optimization Strategies:

  1. Incremental Scans \u2014 Use skipExisting: true to only process new files
  2. Parallel Processing \u2014 Scan multiple directories simultaneously
  3. Background Jobs \u2014 Queue scans as async jobs instead of synchronous requests
  4. Caching \u2014 Cache directory listings in Redis
"},{"location":"v2/features/media/video-library/#ffprobe-extraction","title":"FFprobe Extraction","text":"

Timing:

Batch Processing:

For 100 videos: ~10-20 seconds total

Optimization:

// Parallel extraction (limit concurrency)\nimport pLimit from 'p-limit';\n\nconst limit = pLimit(5); // Max 5 concurrent FFprobe calls\n\nconst results = await Promise.all(\n  videoFiles.map((file) =>\n    limit(() => ffprobeService.extract(file))\n  )\n);\n
"},{"location":"v2/features/media/video-library/#database-queries","title":"Database Queries","text":"

Query Performance:

Optimization:

  1. Always use pagination \u2014 Never fetch all records
  2. Index heavily filtered columns \u2014 directoryType, orientation, quality, isValid
  3. Use SELECT only needed columns \u2014 Avoid SELECT * for large tables
  4. Cache counts \u2014 Total video count changes infrequently, cache in Redis
"},{"location":"v2/features/media/video-library/#thumbnail-generation","title":"Thumbnail Generation","text":"

Deferred Loading:

Don't generate thumbnails during scan. Instead:

  1. Create video record without thumbnail
  2. Queue thumbnail generation job
  3. Worker processes job asynchronously
  4. Update record with thumbnailPath

Lazy Loading:

Frontend requests thumbnails only when visible (IntersectionObserver).

"},{"location":"v2/features/media/video-library/#dual-api-architecture","title":"Dual API Architecture","text":""},{"location":"v2/features/media/video-library/#why-separate-fastify-api","title":"Why Separate Fastify API?","text":"

The media system was introduced as a Phase 14 enhancement after V2 core functionality stabilized. A separate Fastify microservice was chosen to:

  1. Avoid Disrupting Stable Express API \u2014 V2 Express API battle-tested with 30+ models, introducing media directly risked regressions
  2. Test Drizzle ORM Migration \u2014 Fastify+Drizzle serves as proof-of-concept for potential future Prisma\u2192Drizzle migration
  3. Isolate Video Processing \u2014 CPU/GPU-intensive FFprobe, encoding jobs isolated from main API request handling
  4. Independent Scaling \u2014 Media API can be horizontally scaled separately based on video processing load
  5. Technology Experimentation \u2014 Fastify's performance benefits evaluated for potential broader adoption
"},{"location":"v2/features/media/video-library/#database-sharing-strategy","title":"Database Sharing Strategy","text":"

Same PostgreSQL, Different ORMs:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502  PostgreSQL 16  \u2502\n\u2502 v2_changemaker  \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n         \u2191\n    \u250c\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2510\n    \u2502         \u2502\n\u250c\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2510\n\u2502Prisma \u2502 \u2502Drizzle\u2502\n\u2502 ORM   \u2502 \u2502  ORM  \u2502\n\u2514\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2518\n    \u2502        \u2502\n\u250c\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502Express \u2502 \u2502Fastify\u2502\n\u2502  API   \u2502 \u2502 Media \u2502\n\u2502 :4000  \u2502 \u2502  API  \u2502\n\u2502        \u2502 \u2502 :4100 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n

Benefits:

Challenges:

"},{"location":"v2/features/media/video-library/#migration-strategy-roadmap","title":"Migration Strategy Roadmap","text":"

Short Term (Current):

Medium Term (6-12 months):

Long Term (12+ months):

Migration Effort Estimate:

"},{"location":"v2/features/media/video-library/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/media/video-library/#backend-documentation","title":"Backend Documentation","text":""},{"location":"v2/features/media/video-library/#frontend-documentation","title":"Frontend Documentation","text":""},{"location":"v2/features/media/video-library/#database-documentation","title":"Database Documentation","text":""},{"location":"v2/features/media/video-library/#feature-documentation","title":"Feature Documentation","text":""},{"location":"v2/features/media/video-library/#integration-documentation","title":"Integration Documentation","text":""},{"location":"v2/features/media/video-library/#next-steps","title":"Next Steps","text":"

After mastering video library management:

  1. Upload System \u2014 Read features/media/upload.md to understand video upload workflow
  2. Jobs Queue \u2014 Review features/media/jobs.md for video processing automation
  3. Public Gallery \u2014 Explore features/media/public-gallery.md for sharing videos publicly
  4. Custom Integrations \u2014 Use Media API endpoints to build custom video features

For hands-on practice, try:

# 1. Upload test videos\ncurl -X POST http://localhost:4100/api/media/upload/single \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -F \"video=@test.mp4\" \\\n  -F \"producer=Test Studio\" \\\n  -F \"title=Test Video\"\n\n# 2. Scan directory\ncurl -X POST http://localhost:4100/api/media/videos/scan \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"directoryType\": \"videos\"}'\n\n# 3. List videos\ncurl http://localhost:4100/api/media/videos?page=1&limit=10\n\n# 4. Validate video\ncurl -X POST http://localhost:4100/api/media/videos/VIDEO_ID/validate \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n

Last Updated: 2026-02-13 Version: V2.0 Maintainer: Changemaker Lite Team

"},{"location":"v2/features/newsletter/","title":"Newsletter Integration (Listmonk)","text":"

The Newsletter Integration provides automated synchronization between Changemaker Lite and Listmonk newsletter platform. Campaign participants, volunteers, and locations can be automatically synced to Listmonk lists for targeted email campaigns.

"},{"location":"v2/features/newsletter/#overview","title":"Overview","text":"

The Listmonk integration provides:

"},{"location":"v2/features/newsletter/#features","title":"Features","text":""},{"location":"v2/features/newsletter/#subscriber-sync","title":"Subscriber Sync","text":"

Automatically sync users to Listmonk:

"},{"location":"v2/features/newsletter/#list-management","title":"List Management","text":"

Auto-create and manage lists:

"},{"location":"v2/features/newsletter/#sync-triggers","title":"Sync Triggers","text":"

Automatic sync on:

"},{"location":"v2/features/newsletter/#admin-controls","title":"Admin Controls","text":""},{"location":"v2/features/newsletter/#architecture","title":"Architecture","text":""},{"location":"v2/features/newsletter/#backend-components","title":"Backend Components","text":"

Listmonk Client: - api/src/services/listmonk.client.ts - Typed HTTP client (native fetch) - Basic auth integration - Full REST API coverage

Listmonk Sync Service: - api/src/services/listmonk-sync.service.ts - Sync orchestration - Participant \u2192 subscriber mapping - List creation and management - Error handling and logging

Admin Module: - api/src/modules/listmonk/listmonk.routes.ts - Admin endpoints - Status, stats, sync controls

Database: - No new tables (uses existing User, Campaign, Location) - Listmonk IDs stored in Prisma models (future)

"},{"location":"v2/features/newsletter/#frontend-components","title":"Frontend Components","text":"

Admin Page: - admin/src/pages/ListmonkPage.tsx - Newsletter management - Connection status display - Sync controls - List statistics table

"},{"location":"v2/features/newsletter/#configuration","title":"Configuration","text":""},{"location":"v2/features/newsletter/#environment-variables","title":"Environment Variables","text":"
# Enable Listmonk sync (opt-in)\nLISTMONK_SYNC_ENABLED=true\n\n# Listmonk connection\nLISTMONK_API_URL=http://listmonk:9000\nLISTMONK_API_USER=api_user\nLISTMONK_API_TOKEN=your_api_token\n\n# Web admin credentials (for setup)\nLISTMONK_WEB_ADMIN_USER=admin\nLISTMONK_WEB_ADMIN_PASSWORD=password\n
"},{"location":"v2/features/newsletter/#docker-setup","title":"Docker Setup","text":"

Listmonk runs as a service in docker-compose.yml:

listmonk:\n  image: listmonk/listmonk:latest\n  ports:\n    - \"9001:9000\"\n  depends_on:\n    - listmonk-db\n  environment:\n    LISTMONK_app__admin_username: ${LISTMONK_WEB_ADMIN_USER}\n    LISTMONK_app__admin_password: ${LISTMONK_WEB_ADMIN_PASSWORD}\n
"},{"location":"v2/features/newsletter/#initialization","title":"Initialization","text":"

Auto-create API user via listmonk-init container:

INSERT INTO users (email, name, password, type, status, created_at, updated_at)\nVALUES (\n  '${LISTMONK_API_USER}',\n  'API User',\n  '${LISTMONK_API_TOKEN}',  -- Plaintext (Listmonk API tokens)\n  'api',\n  'enabled',\n  NOW(),\n  NOW()\n);\n
"},{"location":"v2/features/newsletter/#sync-process","title":"Sync Process","text":""},{"location":"v2/features/newsletter/#campaign-participant-sync","title":"Campaign Participant Sync","text":"
  1. Email Sent - Campaign email sent via API
  2. Create Subscriber - POST /api/subscribers
  3. Email, name from user
  4. Status: enabled
  5. Get/Create List - GET/POST /api/lists
  6. List name: Campaign name
  7. Type: public or private
  8. Subscribe to List - PUT /api/subscribers/:id/lists
  9. Add subscriber to campaign list
"},{"location":"v2/features/newsletter/#location-sync","title":"Location Sync","text":"
  1. Location Created - New location added
  2. Get/Create List - List name: Location name/city
  3. Sync Users - All users in location \u2192 list
"},{"location":"v2/features/newsletter/#user-role-sync","title":"User Role Sync","text":"
  1. User Registration - New user account
  2. Get Role List - SUPER_ADMIN, INFLUENCE_ADMIN, etc.
  3. Subscribe User - Add to role-based list
"},{"location":"v2/features/newsletter/#api-integration","title":"API Integration","text":""},{"location":"v2/features/newsletter/#listmonk-client-usage","title":"Listmonk Client Usage","text":"
import { listmonkClient } from '../services/listmonk.client';\n\n// Create subscriber\nconst subscriber = await listmonkClient.createSubscriber({\n  email: 'user@example.com',\n  name: 'User Name',\n  status: 'enabled',\n  lists: [listId],\n});\n\n// Get/Create list\nlet list = await listmonkClient.getListByName('Campaign Name');\nif (!list) {\n  list = await listmonkClient.createList({\n    name: 'Campaign Name',\n    type: 'public',\n    optin: 'double',\n  });\n}\n\n// Subscribe to list\nawait listmonkClient.subscribeToList(subscriberId, [listId]);\n
"},{"location":"v2/features/newsletter/#sync-service-usage","title":"Sync Service Usage","text":"
import { listmonkSyncService } from '../services/listmonk-sync.service';\n\n// Sync campaign participant\nawait listmonkSyncService.syncCampaignParticipant(\n  campaign.id,\n  user.email,\n  user.name\n);\n\n// Sync all participants\nawait listmonkSyncService.syncAllParticipants(campaign.id);\n\n// Sync location members\nawait listmonkSyncService.syncLocationMembers(location.id);\n
"},{"location":"v2/features/newsletter/#admin-interface","title":"Admin Interface","text":""},{"location":"v2/features/newsletter/#connection-status","title":"Connection Status","text":"

Display: - Connected/disconnected status - Listmonk version - API endpoint - Last sync time

"},{"location":"v2/features/newsletter/#sync-controls","title":"Sync Controls","text":"

Buttons: - Sync All Participants - Sync all campaign participants - Sync All Locations - Sync all location members - Test Connection - Verify API access - Reinitialize - Reset lists and subscribers

"},{"location":"v2/features/newsletter/#list-statistics","title":"List Statistics","text":"

Table showing: - List name - Subscriber count - Campaign/location association - Last updated time

"},{"location":"v2/features/newsletter/#security","title":"Security","text":""},{"location":"v2/features/newsletter/#api-authentication","title":"API Authentication","text":"

Listmonk v6+ requires auth on all endpoints:

const headers = {\n  'Authorization': `Basic ${btoa(`${apiUser}:${apiToken}`)}`,\n  'Content-Type': 'application/json',\n};\n
"},{"location":"v2/features/newsletter/#token-storage","title":"Token Storage","text":"

API tokens stored as plaintext in Listmonk DB: - Not bcrypt hashed - Direct upsert possible - Secure via Redis authentication

"},{"location":"v2/features/newsletter/#data-privacy","title":"Data Privacy","text":""},{"location":"v2/features/newsletter/#error-handling","title":"Error Handling","text":""},{"location":"v2/features/newsletter/#sync-failures","title":"Sync Failures","text":"

Handled gracefully: - Network errors logged - Failed syncs retried - Admin notifications - Error statistics

"},{"location":"v2/features/newsletter/#rate-limiting","title":"Rate Limiting","text":"

Respect Listmonk limits: - Batch operations - Delay between requests - Queue large syncs

"},{"location":"v2/features/newsletter/#listmonk-features","title":"Listmonk Features","text":""},{"location":"v2/features/newsletter/#campaign-management","title":"Campaign Management","text":"

Listmonk provides: - Email campaign creation - Template management - Scheduling - A/B testing - Analytics

"},{"location":"v2/features/newsletter/#subscriber-management","title":"Subscriber Management","text":""},{"location":"v2/features/newsletter/#analytics","title":"Analytics","text":""},{"location":"v2/features/newsletter/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/newsletter/#admin-endpoints","title":"Admin Endpoints","text":"
GET    /api/listmonk/status            # Connection status\nGET    /api/listmonk/stats             # Sync statistics\nPOST   /api/listmonk/sync-participants # Sync campaign participants\nPOST   /api/listmonk/sync-locations    # Sync location members\nPOST   /api/listmonk/test-connection   # Test API connection\nPOST   /api/listmonk/reinitialize      # Reset and reinitialize\n
"},{"location":"v2/features/newsletter/#limitations","title":"Limitations","text":""},{"location":"v2/features/newsletter/#current-limitations","title":"Current Limitations","text":""},{"location":"v2/features/newsletter/#future-enhancements","title":"Future Enhancements","text":""},{"location":"v2/features/newsletter/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/newsletter/#connection-issues","title":"Connection Issues","text":"
  1. Check LISTMONK_SYNC_ENABLED=true
  2. Verify LISTMONK_API_URL reachable
  3. Confirm API user created
  4. Test credentials with curl
"},{"location":"v2/features/newsletter/#sync-failures_1","title":"Sync Failures","text":"
  1. Check logs for errors
  2. Verify Listmonk database
  3. Test API connection
  4. Reinitialize if needed
"},{"location":"v2/features/newsletter/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/observability/","title":"Observability & Monitoring","text":"

The Observability feature provides comprehensive monitoring, metrics collection, and alerting for the Changemaker Lite platform. Built on the Prometheus ecosystem with Grafana dashboards and Alertmanager integration.

"},{"location":"v2/features/observability/#overview","title":"Overview","text":"

The Observability stack consists of:

  1. Prometheus - Metrics collection and storage
  2. Grafana - Visualization dashboards
  3. Alertmanager - Alert routing and notifications
  4. Custom Metrics - 12 domain-specific cm_* metrics
  5. HTTP Metrics - Request tracking and performance
  6. Service Health - External service monitoring
"},{"location":"v2/features/observability/#features","title":"Features","text":""},{"location":"v2/features/observability/#metrics-collection","title":"Metrics Collection","text":"

Custom Domain Metrics (12 total):

Counters: - cm_api_uptime_seconds - API uptime counter - cm_canvass_visits_total - Total canvass visits - cm_campaign_emails_sent_total - Total campaign emails sent - cm_geocode_requests_total - Total geocode requests

Gauges: - cm_canvass_sessions_active - Active canvass sessions - cm_email_queue_size - Email queue depth - cm_geocode_queue_size - Geocode queue depth - cm_external_service_health - Service health (0/1)

Histograms: - cm_geocode_duration_seconds - Geocoding latency - http_request_duration_ms - HTTP request duration

HTTP Metrics: - Request count by method/route/status - Request duration percentiles (p50, p95, p99) - Active requests gauge - Error rate tracking

"},{"location":"v2/features/observability/#grafana-dashboards","title":"Grafana Dashboards","text":"

Three pre-configured dashboards:

  1. Changemaker Lite Overview - System-wide metrics
  2. API uptime and request rates
  3. Queue sizes and health
  4. Active sessions
  5. Error rates

  6. Canvassing Metrics - Canvass-specific metrics

  7. Active sessions over time
  8. Visits by outcome
  9. Session duration
  10. Volunteer leaderboard

  11. External Services - Integration health

  12. Redis health
  13. PostgreSQL health
  14. Listmonk status
  15. Geocoding providers
"},{"location":"v2/features/observability/#alert-rules","title":"Alert Rules","text":"

12 predefined alert rules:

Critical Alerts: - API down (>5 min) - Database unreachable - Redis connection lost

Warning Alerts: - High error rate (>5%) - Queue backup (>1000 jobs) - Slow requests (p95 >2s) - Service degradation

Info Alerts: - New deployment - Service restart - Configuration change

"},{"location":"v2/features/observability/#admin-interface","title":"Admin Interface","text":"

Observability page (/app/observability) with:

"},{"location":"v2/features/observability/#architecture","title":"Architecture","text":""},{"location":"v2/features/observability/#backend-components","title":"Backend Components","text":"

Metrics Module: - api/src/utils/metrics.ts - Prometheus metrics definitions - api/src/modules/observability/observability.routes.ts - Admin API

Instrumentation: - Express middleware for HTTP metrics - Service-level metric updates - Queue size tracking - External service health checks

Configuration: - configs/prometheus/prometheus.yml - Scrape config - configs/prometheus/alerts.yml - Alert rules - configs/grafana/dashboards/ - Dashboard JSON

"},{"location":"v2/features/observability/#frontend-components","title":"Frontend Components","text":"

Admin Page: - admin/src/pages/ObservabilityPage.tsx - Monitoring dashboard - Three tabs: Metrics, Dashboards, Alerts - Embedded Grafana iframes - Live metric cards

Observability Components: - admin/src/components/observability/MetricsChart.tsx - Chart component - admin/src/components/observability/ServiceHealthCard.tsx - Health display

"},{"location":"v2/features/observability/#docker-services","title":"Docker Services","text":"

Monitoring Profile:

Services run with --profile monitoring:

profiles: [monitoring]\n  prometheus:\n    image: prom/prometheus:latest\n    ports: [\"9090:9090\"]\n\n  grafana:\n    image: grafana/grafana:latest\n    ports: [\"3001:3000\"]\n\n  alertmanager:\n    image: prom/alertmanager:latest\n    ports: [\"9093:9093\"]\n\n  cadvisor:\n    image: gcr.io/cadvisor/cadvisor:latest\n    ports: [\"8080:8080\"]\n\n  node-exporter:\n    image: prom/node-exporter:latest\n    ports: [\"9100:9100\"]\n\n  redis-exporter:\n    image: oliver006/redis_exporter:latest\n    ports: [\"9121:9121\"]\n
"},{"location":"v2/features/observability/#configuration","title":"Configuration","text":""},{"location":"v2/features/observability/#environment-variables","title":"Environment Variables","text":"
# Enable metrics\nMETRICS_ENABLED=true\n\n# Prometheus\nPROMETHEUS_PORT=9090\n\n# Grafana\nGRAFANA_PORT=3001\nGRAFANA_ADMIN_USER=admin\nGRAFANA_ADMIN_PASSWORD=admin\n\n# Alertmanager\nALERTMANAGER_PORT=9093\n
"},{"location":"v2/features/observability/#prometheus-scrape-targets","title":"Prometheus Scrape Targets","text":"
scrape_configs:\n  - job_name: 'changemaker-api'\n    static_configs:\n      - targets: ['api:4000']\n\n  - job_name: 'media-api'\n    static_configs:\n      - targets: ['media-api:4100']\n\n  - job_name: 'redis'\n    static_configs:\n      - targets: ['redis-exporter:9121']\n\n  - job_name: 'node'\n    static_configs:\n      - targets: ['node-exporter:9100']\n\n  - job_name: 'cadvisor'\n    static_configs:\n      - targets: ['cadvisor:8080']\n
"},{"location":"v2/features/observability/#alert-rules_1","title":"Alert Rules","text":"

Example alert rule:

groups:\n  - name: api_alerts\n    rules:\n      - alert: APIDown\n        expr: up{job=\"changemaker-api\"} == 0\n        for: 5m\n        labels:\n          severity: critical\n        annotations:\n          summary: \"API is down\"\n          description: \"API has been down for 5 minutes\"\n\n      - alert: HighErrorRate\n        expr: rate(http_request_duration_ms_count{status=~\"5..\"}[5m]) > 0.05\n        for: 10m\n        labels:\n          severity: warning\n        annotations:\n          summary: \"High error rate detected\"\n
"},{"location":"v2/features/observability/#metrics-usage","title":"Metrics Usage","text":""},{"location":"v2/features/observability/#increment-counter","title":"Increment Counter","text":"
import { metrics } from '../utils/metrics';\n\n// Campaign email sent\nmetrics.campaignEmailsSent.inc();\n\n// Geocode request\nmetrics.geocodeRequests.inc({ provider: 'nominatim' });\n
"},{"location":"v2/features/observability/#set-gauge","title":"Set Gauge","text":"
// Update queue size\nmetrics.emailQueueSize.set(queueSize);\n\n// Update active sessions\nmetrics.canvassSessionsActive.set(activeSessions);\n\n// Set service health (1 = healthy, 0 = unhealthy)\nmetrics.externalServiceHealth.set({ service: 'redis' }, 1);\n
"},{"location":"v2/features/observability/#observe-histogram","title":"Observe Histogram","text":"
// Time geocoding request\nconst end = metrics.geocodeDuration.startTimer();\ntry {\n  await geocode(address);\n  end({ success: 'true' });\n} catch (error) {\n  end({ success: 'false' });\n}\n
"},{"location":"v2/features/observability/#grafana-dashboards_1","title":"Grafana Dashboards","text":""},{"location":"v2/features/observability/#dashboard-setup","title":"Dashboard Setup","text":"

Dashboards auto-provisioned from configs/grafana/dashboards/:

{\n  \"dashboard\": {\n    \"title\": \"Changemaker Lite Overview\",\n    \"panels\": [\n      {\n        \"title\": \"API Request Rate\",\n        \"targets\": [\n          {\n            \"expr\": \"rate(http_request_duration_ms_count[5m])\"\n          }\n        ]\n      }\n    ]\n  }\n}\n
"},{"location":"v2/features/observability/#accessing-dashboards","title":"Accessing Dashboards","text":""},{"location":"v2/features/observability/#alertmanager","title":"Alertmanager","text":""},{"location":"v2/features/observability/#alert-routing","title":"Alert Routing","text":"

Configure in configs/alertmanager/alertmanager.yml:

route:\n  receiver: 'default'\n  group_by: ['alertname', 'severity']\n  routes:\n    - match:\n        severity: critical\n      receiver: 'critical-alerts'\n\nreceivers:\n  - name: 'default'\n    webhook_configs:\n      - url: 'http://gotify:8889/message'\n\n  - name: 'critical-alerts'\n    email_configs:\n      - to: 'admin@example.com'\n
"},{"location":"v2/features/observability/#notification-channels","title":"Notification Channels","text":"

Supported receivers:

"},{"location":"v2/features/observability/#service-health-monitoring","title":"Service Health Monitoring","text":""},{"location":"v2/features/observability/#external-service-checks","title":"External Service Checks","text":"

Monitor services via health gauges:

// Check Redis\ntry {\n  await redisClient.ping();\n  metrics.externalServiceHealth.set({ service: 'redis' }, 1);\n} catch (error) {\n  metrics.externalServiceHealth.set({ service: 'redis' }, 0);\n}\n\n// Check PostgreSQL\ntry {\n  await prisma.$queryRaw`SELECT 1`;\n  metrics.externalServiceHealth.set({ service: 'postgres' }, 1);\n} catch (error) {\n  metrics.externalServiceHealth.set({ service: 'postgres' }, 0);\n}\n
"},{"location":"v2/features/observability/#docker-healthchecks","title":"Docker Healthchecks","text":"

Services with healthchecks:

"},{"location":"v2/features/observability/#performance-monitoring","title":"Performance Monitoring","text":""},{"location":"v2/features/observability/#http-request-tracking","title":"HTTP Request Tracking","text":"

Automatic tracking of:

"},{"location":"v2/features/observability/#queue-monitoring","title":"Queue Monitoring","text":"

Track queue depths:

"},{"location":"v2/features/observability/#resource-monitoring","title":"Resource Monitoring","text":"

Via cAdvisor and Node Exporter:

"},{"location":"v2/features/observability/#admin-interface_1","title":"Admin Interface","text":""},{"location":"v2/features/observability/#metrics-tab","title":"Metrics Tab","text":"

Display cards:

"},{"location":"v2/features/observability/#dashboards-tab","title":"Dashboards Tab","text":"

Embedded Grafana:

"},{"location":"v2/features/observability/#alerts-tab","title":"Alerts Tab","text":"

Active alerts list:

"},{"location":"v2/features/observability/#starting-monitoring-stack","title":"Starting Monitoring Stack","text":"
# Start with monitoring profile\ndocker compose --profile monitoring up -d\n\n# Access services\n# Prometheus: http://localhost:9090\n# Grafana: http://localhost:3001 (admin/admin)\n# Alertmanager: http://localhost:9093\n
"},{"location":"v2/features/observability/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/observability/#observability-endpoints","title":"Observability Endpoints","text":"
GET    /api/observability/prometheus   # Prometheus status\nGET    /api/observability/grafana      # Grafana status\nGET    /api/observability/alertmanager # Alertmanager status\nGET    /api/observability/metrics      # Current metrics values\n
"},{"location":"v2/features/observability/#metrics-endpoint","title":"Metrics Endpoint","text":"
GET    /metrics                         # Prometheus scrape endpoint\n
"},{"location":"v2/features/observability/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/pages/block-library/","title":"Block Library","text":"

Reusable page component system with JSON schema definitions, default values, and campaign-specific customization.

"},{"location":"v2/features/pages/block-library/#overview","title":"Overview","text":"

The Block Library provides a database-driven system for managing reusable page components (blocks) in the GrapesJS editor. Administrators can use pre-configured blocks or create custom ones tailored to their campaign needs.

"},{"location":"v2/features/pages/block-library/#key-features","title":"Key Features","text":""},{"location":"v2/features/pages/block-library/#architecture","title":"Architecture","text":"
graph LR\n    A[(PageBlock Table)] -->|GET /api/page-blocks| B[API Service]\n    B --> C[LandingPageEditor]\n    C --> D[GrapesJSEditor]\n    D --> E[BlockManager]\n    E --> F[Left Panel]\n\n    G[Admin] -->|POST /api/page-blocks| B\n    G -->|Define Schema| H[JSON Schema]\n    G -->|Set Defaults| I[Default Values]\n    H --> A\n    I --> A\n\n    style A fill:#3498db\n    style E fill:#9d4edd\n    style F fill:#2ecc71

Flow:

  1. Seed: Default blocks created in api/prisma/seed.ts
  2. Fetch: Editor loads all blocks via GET /api/page-blocks
  3. Register: GrapesJSEditor registers each block with BlockManager
  4. Render: Blocks appear in left panel (grouped by category)
  5. Customize: Admin creates custom blocks via API (future enhancement)
"},{"location":"v2/features/pages/block-library/#database-model","title":"Database Model","text":""},{"location":"v2/features/pages/block-library/#pageblock-table","title":"PageBlock Table","text":"

Schema:

model PageBlock {\n  id         String   @id @default(uuid())\n  type       String   @unique // Block type identifier (e.g., 'hero', 'text')\n  label      String   // Display name in editor (\"Hero Section\")\n  category   String?  // Group blocks (\"Headers\", \"Content\", \"Actions\")\n  sortOrder  Int      @default(0) // Position in left panel\n  schema     Json     // JSON schema for configurable properties\n  defaults   Json     // Default values for schema fields\n  thumbnail  String?  // Preview image URL (future enhancement)\n  createdAt  DateTime @default(now())\n  updatedAt  DateTime @updatedAt\n\n  @@index([category, sortOrder])\n}\n

Fields:

Field Type Description id String (UUID) Primary key type String Unique identifier (e.g., \"hero\", \"features\") label String Human-readable name shown in editor category String? Group blocks in collapsible sections sortOrder Int Order within category (lower = higher in list) schema JSON Property definitions (field name, type, label) defaults JSON Default values for each schema field thumbnail String? Preview image URL (not implemented)

Indexes:

"},{"location":"v2/features/pages/block-library/#default-blocks","title":"Default Blocks","text":""},{"location":"v2/features/pages/block-library/#1-hero-section","title":"1. Hero Section","text":"

Type: hero

Category: Headers

Schema:

{\n  \"title\": { \"type\": \"string\", \"label\": \"Title\" },\n  \"subtitle\": { \"type\": \"string\", \"label\": \"Subtitle\" },\n  \"backgroundImage\": { \"type\": \"string\", \"label\": \"Background Image URL\" },\n  \"ctaText\": { \"type\": \"string\", \"label\": \"Button Text\" },\n  \"ctaUrl\": { \"type\": \"string\", \"label\": \"Button URL\" }\n}\n

Defaults:

{\n  \"title\": \"Welcome to Our Campaign\",\n  \"subtitle\": \"Join us in making a difference in your community.\",\n  \"backgroundImage\": \"\",\n  \"ctaText\": \"Get Involved\",\n  \"ctaUrl\": \"#\"\n}\n

Rendered HTML:

<section style=\"padding: 80px 40px; text-align: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: #fff;\">\n  <h1 style=\"font-size: 2.5rem; margin-bottom: 16px;\">Welcome to Our Campaign</h1>\n  <p style=\"font-size: 1.25rem; opacity: 0.85; margin-bottom: 32px;\">Join us in making a difference in your community.</p>\n  <a href=\"#\" style=\"display: inline-block; padding: 12px 32px; background: #9d4edd; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600;\">Get Involved</a>\n</section>\n
"},{"location":"v2/features/pages/block-library/#2-text-block","title":"2. Text Block","text":"

Type: text

Category: Content

Schema:

{\n  \"heading\": { \"type\": \"string\", \"label\": \"Heading\" },\n  \"body\": { \"type\": \"text\", \"label\": \"Body Text\" }\n}\n

Defaults:

{\n  \"heading\": \"About Us\",\n  \"body\": \"Tell your story here. Explain your mission, values, and what drives your campaign forward.\"\n}\n

Rendered HTML:

<section style=\"padding: 60px 40px; max-width: 800px; margin: 0 auto;\">\n  <h2 style=\"font-size: 1.75rem; margin-bottom: 16px;\">About Us</h2>\n  <p style=\"font-size: 1rem; line-height: 1.7; opacity: 0.85;\">Tell your story here. Explain your mission, values, and what drives your campaign forward.</p>\n</section>\n
"},{"location":"v2/features/pages/block-library/#3-features-grid","title":"3. Features Grid","text":"

Type: features

Category: Content

Schema:

{\n  \"features\": {\n    \"type\": \"array\",\n    \"label\": \"Features\",\n    \"items\": {\n      \"title\": \"string\",\n      \"description\": \"string\",\n      \"icon\": \"string\"\n    }\n  }\n}\n

Defaults:

{\n  \"features\": [\n    { \"title\": \"Community Action\", \"description\": \"Organize local events and initiatives.\", \"icon\": \"\" },\n    { \"title\": \"Advocacy\", \"description\": \"Email your representatives directly.\", \"icon\": \"\" },\n    { \"title\": \"Volunteer\", \"description\": \"Sign up for shifts and make a difference.\", \"icon\": \"\" }\n  ]\n}\n

Rendered HTML:

<section style=\"padding: 60px 40px;\">\n  <div style=\"display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;\">\n    <div style=\"flex: 1; min-width: 250px; padding: 24px; text-align: center;\">\n      <h3 style=\"font-size: 1.25rem; margin-bottom: 8px;\">Community Action</h3>\n      <p style=\"opacity: 0.8;\">Organize local events and initiatives.</p>\n    </div>\n    <div style=\"flex: 1; min-width: 250px; padding: 24px; text-align: center;\">\n      <h3 style=\"font-size: 1.25rem; margin-bottom: 8px;\">Advocacy</h3>\n      <p style=\"opacity: 0.8;\">Email your representatives directly.</p>\n    </div>\n    <div style=\"flex: 1; min-width: 250px; padding: 24px; text-align: center;\">\n      <h3 style=\"font-size: 1.25rem; margin-bottom: 8px;\">Volunteer</h3>\n      <p style=\"opacity: 0.8;\">Sign up for shifts and make a difference.</p>\n    </div>\n  </div>\n</section>\n
"},{"location":"v2/features/pages/block-library/#4-call-to-action","title":"4. Call to Action","text":"

Type: cta

Category: Actions

Schema:

{\n  \"heading\": { \"type\": \"string\", \"label\": \"Heading\" },\n  \"description\": { \"type\": \"string\", \"label\": \"Description\" },\n  \"buttonText\": { \"type\": \"string\", \"label\": \"Button Text\" },\n  \"buttonUrl\": { \"type\": \"string\", \"label\": \"Button URL\" }\n}\n

Defaults:

{\n  \"heading\": \"Ready to Take Action?\",\n  \"description\": \"Join thousands of community members making their voices heard.\",\n  \"buttonText\": \"Join Now\",\n  \"buttonUrl\": \"#\"\n}\n

Rendered HTML:

<section style=\"padding: 60px 40px; text-align: center; background: linear-gradient(135deg, #9d4edd 0%, #7b2cbf 100%); color: #fff;\">\n  <h2 style=\"font-size: 2rem; margin-bottom: 12px;\">Ready to Take Action?</h2>\n  <p style=\"font-size: 1.1rem; margin-bottom: 24px; opacity: 0.9;\">Join thousands of community members making their voices heard.</p>\n  <a href=\"#\" style=\"display: inline-block; padding: 12px 32px; background: #fff; color: #9d4edd; text-decoration: none; border-radius: 6px; font-weight: 600;\">Join Now</a>\n</section>\n
"},{"location":"v2/features/pages/block-library/#5-testimonials","title":"5. Testimonials","text":"

Type: testimonials

Category: Content

Schema:

{\n  \"quotes\": {\n    \"type\": \"array\",\n    \"label\": \"Quotes\",\n    \"items\": {\n      \"text\": \"string\",\n      \"author\": \"string\",\n      \"role\": \"string\"\n    }\n  }\n}\n

Defaults:

{\n  \"quotes\": [\n    { \"text\": \"This platform made it so easy to contact my representatives.\", \"author\": \"Jane D.\", \"role\": \"Community Member\" },\n    { \"text\": \"I signed up for a volunteer shift and it changed my perspective.\", \"author\": \"Mark S.\", \"role\": \"Volunteer\" }\n  ]\n}\n

Rendered HTML:

<section style=\"padding: 60px 40px;\">\n  <div style=\"display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;\">\n    <div style=\"flex: 1; min-width: 280px; padding: 24px; background: rgba(255,255,255,0.05); border-radius: 8px;\">\n      <p style=\"font-style: italic; margin-bottom: 12px;\">\"This platform made it so easy to contact my representatives.\"</p>\n      <p style=\"font-weight: 600; margin-bottom: 2px;\">Jane D.</p>\n      <p style=\"font-size: 0.85rem; opacity: 0.7;\">Community Member</p>\n    </div>\n    <div style=\"flex: 1; min-width: 280px; padding: 24px; background: rgba(255,255,255,0.05); border-radius: 8px;\">\n      <p style=\"font-style: italic; margin-bottom: 12px;\">\"I signed up for a volunteer shift and it changed my perspective.\"</p>\n      <p style=\"font-weight: 600; margin-bottom: 2px;\">Mark S.</p>\n      <p style=\"font-size: 0.85rem; opacity: 0.7;\">Volunteer</p>\n    </div>\n  </div>\n</section>\n
"},{"location":"v2/features/pages/block-library/#6-contact-form","title":"6. Contact Form","text":"

Type: contact-form

Category: Actions

Schema:

{\n  \"heading\": { \"type\": \"string\", \"label\": \"Heading\" },\n  \"fields\": {\n    \"type\": \"array\",\n    \"label\": \"Fields\",\n    \"items\": {\n      \"name\": \"string\",\n      \"type\": \"string\",\n      \"required\": \"boolean\"\n    }\n  }\n}\n

Defaults:

{\n  \"heading\": \"Get in Touch\",\n  \"fields\": [\n    { \"name\": \"name\", \"type\": \"text\", \"required\": true },\n    { \"name\": \"email\", \"type\": \"email\", \"required\": true },\n    { \"name\": \"message\", \"type\": \"textarea\", \"required\": true }\n  ]\n}\n

Rendered HTML:

<section style=\"padding: 60px 40px; max-width: 600px; margin: 0 auto;\">\n  <h2 style=\"text-align: center; margin-bottom: 24px;\">Get in Touch</h2>\n  <form style=\"display: flex; flex-direction: column; gap: 16px;\">\n    <input type=\"text\" placeholder=\"Name\" style=\"padding: 10px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.05); color: inherit;\" />\n    <input type=\"email\" placeholder=\"Email\" style=\"padding: 10px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.05); color: inherit;\" />\n    <textarea placeholder=\"Message\" rows=\"4\" style=\"padding: 10px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.05); color: inherit; resize: vertical;\"></textarea>\n    <button type=\"submit\" style=\"padding: 12px 24px; background: #9d4edd; color: #fff; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;\">Send Message</button>\n  </form>\n</section>\n

Note: Form submission not wired (static HTML). Use grapesjs-plugin-forms for backend integration.

"},{"location":"v2/features/pages/block-library/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/pages/block-library/#admin-routes","title":"Admin Routes","text":"

Prefix: /api/page-blocks

Authentication: Requires admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN)

"},{"location":"v2/features/pages/block-library/#list-blocks","title":"List Blocks","text":"
GET /api/page-blocks?category=Headers\n

Query Parameters:

Response:

[\n  {\n    \"id\": \"default-hero\",\n    \"type\": \"hero\",\n    \"label\": \"Hero Section\",\n    \"category\": \"Headers\",\n    \"sortOrder\": 1,\n    \"schema\": {\n      \"title\": { \"type\": \"string\", \"label\": \"Title\" },\n      \"subtitle\": { \"type\": \"string\", \"label\": \"Subtitle\" }\n    },\n    \"defaults\": {\n      \"title\": \"Welcome to Our Campaign\",\n      \"subtitle\": \"Join us in making a difference.\"\n    },\n    \"thumbnail\": null,\n    \"createdAt\": \"2026-01-10T00:00:00Z\",\n    \"updatedAt\": \"2026-01-10T00:00:00Z\"\n  }\n]\n

Sorting:

"},{"location":"v2/features/pages/block-library/#get-block","title":"Get Block","text":"
GET /api/page-blocks/:id\n

Response: Single PageBlock object

Errors:

"},{"location":"v2/features/pages/block-library/#create-block","title":"Create Block","text":"
POST /api/page-blocks\nContent-Type: application/json\n\n{\n  \"type\": \"campaign-stats\",\n  \"label\": \"Campaign Stats\",\n  \"category\": \"Campaign\",\n  \"sortOrder\": 10,\n  \"schema\": {\n    \"volunteers\": { \"type\": \"number\", \"label\": \"Volunteers\" },\n    \"emails\": { \"type\": \"number\", \"label\": \"Emails Sent\" }\n  },\n  \"defaults\": {\n    \"volunteers\": 1250,\n    \"emails\": 5400\n  }\n}\n

Request Body:

Response: Created PageBlock object (201 status)

Errors:

"},{"location":"v2/features/pages/block-library/#update-block","title":"Update Block","text":"
PUT /api/page-blocks/:id\nContent-Type: application/json\n\n{\n  \"label\": \"Updated Label\",\n  \"defaults\": {\n    \"volunteers\": 2000\n  }\n}\n

Request Body: (all fields optional except constraints)

Response: Updated PageBlock object

Errors:

"},{"location":"v2/features/pages/block-library/#delete-block","title":"Delete Block","text":"
DELETE /api/page-blocks/:id\n

Response: 204 No Content

Errors:

Side Effects:

"},{"location":"v2/features/pages/block-library/#schema-format","title":"Schema Format","text":""},{"location":"v2/features/pages/block-library/#property-types","title":"Property Types","text":"

Supported Types:

Type Description Example string Short text field Title, subtitle, URL text Multi-line text Body paragraph number Numeric value Volunteer count, price boolean True/false toggle Show/hide element array List of items Features, testimonials"},{"location":"v2/features/pages/block-library/#simple-property","title":"Simple Property","text":"
{\n  \"title\": {\n    \"type\": \"string\",\n    \"label\": \"Title\"\n  }\n}\n

Rendered in GrapesJS: Text input labeled \"Title\"

"},{"location":"v2/features/pages/block-library/#array-property","title":"Array Property","text":"
{\n  \"features\": {\n    \"type\": \"array\",\n    \"label\": \"Features\",\n    \"items\": {\n      \"title\": \"string\",\n      \"description\": \"string\",\n      \"icon\": \"string\"\n    }\n  }\n}\n

Rendered in GrapesJS:

"},{"location":"v2/features/pages/block-library/#defaults-matching","title":"Defaults Matching","text":"

Schema:

{\n  \"heading\": { \"type\": \"string\", \"label\": \"Heading\" },\n  \"count\": { \"type\": \"number\", \"label\": \"Count\" }\n}\n

Valid Defaults:

{\n  \"heading\": \"Our Impact\",\n  \"count\": 42\n}\n

Invalid Defaults:

{\n  \"heading\": 123,  // Type mismatch (should be string)\n  \"count\": \"foo\"   // Type mismatch (should be number)\n}\n
"},{"location":"v2/features/pages/block-library/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/pages/block-library/#using-default-blocks","title":"Using Default Blocks","text":"
  1. Open Editor: Admin \u2192 Pages \u2192 Click \"Edit\" on any page
  2. Locate Block: Left panel \u2192 Expand \"Headers\" category
  3. Drag Block: Drag \"Hero Section\" to canvas
  4. Configure: Click block \u2192 Right panel shows properties
  5. Title: \"Join the Movement\"
  6. Subtitle: \"Together we can make a difference.\"
  7. CTA Text: \"Sign Up\"
  8. CTA URL: \"/shifts\"
  9. Save: Press Ctrl+S \u2192 Block HTML stored in database
"},{"location":"v2/features/pages/block-library/#creating-custom-blocks","title":"Creating Custom Blocks","text":"

Note: Custom block creation UI not implemented. Use API directly.

Example: Campaign Stats Block

curl -X POST http://localhost:4000/api/page-blocks \\\n  -H \"Authorization: Bearer $ADMIN_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"type\": \"campaign-stats\",\n    \"label\": \"Campaign Stats\",\n    \"category\": \"Campaign\",\n    \"sortOrder\": 10,\n    \"schema\": {\n      \"volunteers\": { \"type\": \"number\", \"label\": \"Volunteers\" },\n      \"emails\": { \"type\": \"number\", \"label\": \"Emails Sent\" },\n      \"events\": { \"type\": \"number\", \"label\": \"Events\" }\n    },\n    \"defaults\": {\n      \"volunteers\": 1250,\n      \"emails\": 5400,\n      \"events\": 32\n    }\n  }'\n

Result:

"},{"location":"v2/features/pages/block-library/#updating-block-defaults","title":"Updating Block Defaults","text":"

Use Case: Update hero CTA text for all new pages

curl -X PUT http://localhost:4000/api/page-blocks/default-hero \\\n  -H \"Authorization: Bearer $ADMIN_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"defaults\": {\n      \"title\": \"Welcome to Our 2026 Campaign\",\n      \"subtitle\": \"Join us in making a difference.\",\n      \"ctaText\": \"Get Started Today\",\n      \"ctaUrl\": \"/shifts\"\n    }\n  }'\n

Effect:

"},{"location":"v2/features/pages/block-library/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/pages/block-library/#fetching-blocks-for-editor","title":"Fetching Blocks for Editor","text":"
import { api } from '@/lib/api';\nimport type { PageBlock } from '@/types/api';\n\nasync function loadBlocks(): Promise<PageBlock[]> {\n  const { data } = await api.get<PageBlock[]>('/page-blocks');\n  return data.sort((a, b) => {\n    // Sort by category, then sortOrder\n    const catCompare = (a.category || '').localeCompare(b.category || '');\n    return catCompare !== 0 ? catCompare : a.sortOrder - b.sortOrder;\n  });\n}\n
"},{"location":"v2/features/pages/block-library/#creating-custom-block","title":"Creating Custom Block","text":"
async function createCampaignStatsBlock() {\n  const { data } = await api.post<PageBlock>('/page-blocks', {\n    type: 'campaign-stats',\n    label: 'Campaign Stats',\n    category: 'Campaign',\n    sortOrder: 10,\n    schema: {\n      volunteers: { type: 'number', label: 'Volunteers' },\n      emails: { type: 'number', label: 'Emails Sent' },\n      events: { type: 'number', label: 'Events' },\n    },\n    defaults: {\n      volunteers: 1250,\n      emails: 5400,\n      events: 32,\n    },\n  });\n\n  console.log('Created block:', data.id);\n  return data;\n}\n
"},{"location":"v2/features/pages/block-library/#extending-generateblockhtml","title":"Extending generateBlockHtml()","text":"
// In admin/src/components/GrapesJSEditor.tsx\n\nfunction generateBlockHtml(type: string, defaults: Record<string, unknown>): string {\n  switch (type) {\n    // ... existing cases ...\n\n    case 'campaign-stats': {\n      const volunteers = defaults.volunteers || 0;\n      const emails = defaults.emails || 0;\n      const events = defaults.events || 0;\n\n      return `\n        <section style=\"padding: 60px 40px; background: #f8f9fa; text-align: center;\">\n          <h2 style=\"margin-bottom: 32px; font-size: 2rem;\">Our Impact</h2>\n          <div style=\"display: flex; gap: 48px; justify-content: center; flex-wrap: wrap;\">\n            <div>\n              <div style=\"font-size: 3rem; font-weight: 700; color: #9d4edd;\">${(volunteers as number).toLocaleString()}</div>\n              <div style=\"font-size: 1rem; color: #666;\">Volunteers</div>\n            </div>\n            <div>\n              <div style=\"font-size: 3rem; font-weight: 700; color: #9d4edd;\">${(emails as number).toLocaleString()}</div>\n              <div style=\"font-size: 1rem; color: #666;\">Emails Sent</div>\n            </div>\n            <div>\n              <div style=\"font-size: 3rem; font-weight: 700; color: #9d4edd;\">${events}</div>\n              <div style=\"font-size: 1rem; color: #666;\">Events</div>\n            </div>\n          </div>\n        </section>`;\n    }\n\n    default:\n      return `<section style=\"padding: 40px; text-align: center;\"><p>Custom block: ${type}</p></section>`;\n  }\n}\n
"},{"location":"v2/features/pages/block-library/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/pages/block-library/#problem-block-not-appearing-in-editor","title":"Problem: Block Not Appearing in Editor","text":"

Symptoms:

Causes:

  1. GrapesJSEditor not re-fetching blocks
  2. generateBlockHtml() missing case
  3. Category name mismatch

Solutions:

  1. Reload editor:
  2. Close page editor \u2192 Re-open
  3. Blocks fetched on mount

  4. Add HTML generation case:

    case 'my-new-block':\n  return `<section>My block HTML</section>`;\n

  5. Check category:

    SELECT category FROM page_blocks WHERE type = 'my-new-block';\n-- Category should match GrapesJS panel (case-sensitive)\n

  6. Verify API response:

    curl -H \"Authorization: Bearer $TOKEN\" http://localhost:4000/api/page-blocks\n# Should include new block in response\n

"},{"location":"v2/features/pages/block-library/#problem-default-values-not-applying","title":"Problem: Default Values Not Applying","text":"

Symptoms:

Causes:

  1. Defaults not matching schema keys
  2. HTML template ignores defaults
  3. Type mismatch (string vs number)

Solutions:

  1. Verify defaults match schema:

    // Schema\n{ \"title\": { \"type\": \"string\" } }\n\n// Defaults (good)\n{ \"title\": \"Welcome\" }\n\n// Defaults (bad - key mismatch)\n{ \"heading\": \"Welcome\" }\n

  2. Check HTML template:

    // Good - uses defaults\nreturn `<h1>${defaults.title || 'Fallback'}</h1>`;\n\n// Bad - ignores defaults\nreturn `<h1>Hardcoded Title</h1>`;\n

  3. Fix type mismatch:

    // If schema says \"number\", defaults must be number\n{ \"count\": { \"type\": \"number\" } }\n{ \"count\": 42 }  // Good\n{ \"count\": \"42\" } // Bad\n

"},{"location":"v2/features/pages/block-library/#problem-block-html-not-rendering","title":"Problem: Block HTML Not Rendering","text":"

Symptoms:

Causes:

  1. generateBlockHtml() returns invalid HTML
  2. Inline styles have syntax errors
  3. Missing closing tags

Solutions:

  1. Validate HTML:

    const html = generateBlockHtml('my-block', defaults);\nconsole.log(html); // Check for malformed tags\n

  2. Test inline styles:

    <!-- Bad - missing quotes -->\n<div style=padding: 20px>\n\n<!-- Good - quoted attribute -->\n<div style=\"padding: 20px;\">\n

  3. Use template literals carefully:

    // Ensure all ${} expressions return strings\nreturn `<div>${defaults.title || ''}</div>`;\n

"},{"location":"v2/features/pages/block-library/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/pages/block-library/#block-count-impact","title":"Block Count Impact","text":"

Threshold: 50+ blocks in library

Symptoms:

Mitigations:

  1. Category filtering:
  2. Only fetch blocks for specific category
  3. Lazy-load categories on expand

  4. Pagination:

  5. Load first 20 blocks, fetch more on scroll
  6. Not implemented in current version

  7. Caching:

  8. Store blocks in localStorage
  9. Refresh only when version changes
"},{"location":"v2/features/pages/block-library/#schema-complexity","title":"Schema Complexity","text":"

Issue: Deeply nested array schemas (3+ levels) slow GrapesJS rendering

Example:

{\n  \"sections\": {\n    \"type\": \"array\",\n    \"items\": {\n      \"features\": {\n        \"type\": \"array\",\n        \"items\": {\n          \"details\": {\n            \"type\": \"array\"\n          }\n        }\n      }\n    }\n  }\n}\n

Alternative: Flatten structure or use CODE mode

"},{"location":"v2/features/pages/block-library/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/features/pages/block-library/#admin-only-access","title":"Admin-Only Access","text":"

Protection: All /api/page-blocks endpoints require admin role

router.use(authenticate);\nrouter.use(requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN));\n

Risk: Malicious admin creates XSS block with <script> tags

Mitigation:

"},{"location":"v2/features/pages/block-library/#type-validation","title":"Type Validation","text":"

Attack: Submit block with type containing SQL injection

Protection:

// Zod schema in pages.schemas.ts\ntype: z.string()\n  .min(1)\n  .max(50)\n  .regex(/^[a-z0-9-]+$/, 'Type must be lowercase alphanumeric with hyphens'),\n

Safe types: hero, text-block, campaign-stats-2026

Rejected: '; DROP TABLE--, <script>alert(1)</script>

"},{"location":"v2/features/pages/block-library/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/pages/block-library/#frontend-components","title":"Frontend Components","text":""},{"location":"v2/features/pages/block-library/#backend-modules","title":"Backend Modules","text":""},{"location":"v2/features/pages/block-library/#database","title":"Database","text":""},{"location":"v2/features/pages/block-library/#features","title":"Features","text":""},{"location":"v2/features/pages/block-library/#seed-data","title":"Seed Data","text":""},{"location":"v2/features/pages/grapes-editor/","title":"GrapesJS Editor Integration","text":"

React wrapper component for GrapesJS WYSIWYG editor with forwardRef pattern, custom block registration, and keyboard shortcuts.

"},{"location":"v2/features/pages/grapes-editor/#overview","title":"Overview","text":"

The GrapesJS Editor component provides a production-ready integration of the GrapesJS page builder library into the Changemaker Lite admin interface. It handles initialization, plugin configuration, custom block registration, and save orchestration.

"},{"location":"v2/features/pages/grapes-editor/#key-features","title":"Key Features","text":""},{"location":"v2/features/pages/grapes-editor/#architecture","title":"Architecture","text":"
graph TD\n    A[LandingPageEditor] -->|ref| B[GrapesJSEditor]\n    B -->|useImperativeHandle| C[triggerSave handle]\n    B --> D[grapesjs.init]\n    D --> E[Load Plugins]\n    E --> F[Register Custom Blocks]\n    F --> G[Load Initial Data]\n    G --> H[Canvas Ready]\n\n    A -->|handleSave| I[editorRef.current.triggerSave]\n    I --> J[Commands.run save-page]\n    J --> K[getProjectData + getHtml + getCss]\n    K --> L[onSave callback]\n    L --> M[API PUT /pages/:id]\n\n    style B fill:#9d4edd\n    style D fill:#3498db\n    style M fill:#2ecc71

Flow:

  1. Mount: LandingPageEditor creates ref, renders GrapesJSEditor
  2. Init: GrapesJSEditor calls grapesjs.init() \u2192 Loads plugins
  3. Blocks: Registers custom blocks from PageBlock library
  4. Data: Loads initialData (GrapesJS projectData JSON)
  5. Expose: useImperativeHandle exposes triggerSave() method
  6. Save: Parent calls editorRef.current.triggerSave() \u2192 Runs save-page command
  7. Callback: GrapesJS extracts HTML/CSS \u2192 Calls onSave() \u2192 Parent saves to API
"},{"location":"v2/features/pages/grapes-editor/#component-api","title":"Component API","text":""},{"location":"v2/features/pages/grapes-editor/#props","title":"Props","text":"
interface GrapesJSEditorProps {\n  initialData?: Record<string, unknown>;\n  onSave: (data: { projectData: Record<string, unknown>; html: string; css: string }) => void;\n  customBlocks?: PageBlock[];\n}\n

Fields:

"},{"location":"v2/features/pages/grapes-editor/#ref-handle","title":"Ref Handle","text":"
interface GrapesJSEditorHandle {\n  triggerSave: () => void;\n}\n

Method:

"},{"location":"v2/features/pages/grapes-editor/#usage-example","title":"Usage Example","text":"
import { useRef } from 'react';\nimport GrapesJSEditor, { GrapesJSEditorHandle } from '@/components/GrapesJSEditor';\n\nfunction MyEditor() {\n  const editorRef = useRef<GrapesJSEditorHandle>(null);\n\n  const handleSave = async (data) => {\n    await api.put('/pages/123', {\n      blocks: data.projectData,\n      htmlOutput: data.html,\n      cssOutput: data.css,\n    });\n  };\n\n  const handleManualSave = () => {\n    editorRef.current?.triggerSave();\n  };\n\n  return (\n    <div>\n      <button onClick={handleManualSave}>Save</button>\n      <GrapesJSEditor\n        ref={editorRef}\n        initialData={page.blocks}\n        onSave={handleSave}\n        customBlocks={blocks}\n      />\n    </div>\n  );\n}\n
"},{"location":"v2/features/pages/grapes-editor/#grapesjs-configuration","title":"GrapesJS Configuration","text":""},{"location":"v2/features/pages/grapes-editor/#initialization-options","title":"Initialization Options","text":"
const editor = grapesjs.init({\n  container: containerRef.current,\n  height: '100%',\n  width: 'auto',\n  storageManager: false, // No localStorage persistence (managed by API)\n  plugins: [\n    blocksBasicPlugin,\n    presetWebpagePlugin,\n    formsPlugin,\n    navbarPlugin,\n    countdownPlugin,\n    tabsPlugin,\n    typedPlugin,\n    customCodePlugin,\n    exportPlugin,\n    styleGradientPlugin,\n    touchPlugin,\n  ],\n  pluginsOpts: {\n    [blocksBasicPlugin]: { flexGrid: true },\n    // ... other plugin options\n  },\n  canvas: {\n    styles: [\n      'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',\n    ],\n  },\n});\n

Key Settings:

"},{"location":"v2/features/pages/grapes-editor/#plugins-ecosystem","title":"Plugins Ecosystem","text":"Plugin Purpose Features grapesjs-blocks-basic Basic blocks Section, text, image, video, map, link, flexGrid grapesjs-preset-webpage Full page presets Header, footer, hero templates grapesjs-plugin-forms Form components Input, textarea, select, button, checkbox, radio grapesjs-navbar Navigation bars Responsive navbar with dropdowns grapesjs-component-countdown Countdown timers Event countdown with custom styling grapesjs-tabs Tab panels Horizontal/vertical tab containers grapesjs-typed Typing animation Typewriter text effect grapesjs-custom-code Embed raw HTML/JS Custom code blocks (advanced users) grapesjs-plugin-export Export templates ZIP download of HTML/CSS/assets grapesjs-style-gradient Gradient editor Visual gradient picker for backgrounds grapesjs-touch Touch support Mobile/tablet drag-and-drop (experimental)

Installation:

cd admin && npm install \\\n  grapesjs \\\n  grapesjs-blocks-basic \\\n  grapesjs-preset-webpage \\\n  grapesjs-plugin-forms \\\n  grapesjs-navbar \\\n  grapesjs-component-countdown \\\n  grapesjs-tabs \\\n  grapesjs-typed \\\n  grapesjs-custom-code \\\n  grapesjs-plugin-export \\\n  grapesjs-style-gradient \\\n  grapesjs-touch\n
"},{"location":"v2/features/pages/grapes-editor/#custom-blocks-registration","title":"Custom Blocks Registration","text":""},{"location":"v2/features/pages/grapes-editor/#block-registration-flow","title":"Block Registration Flow","text":"
sequenceDiagram\n    participant API as API Database\n    participant Parent as LandingPageEditor\n    participant Editor as GrapesJSEditor\n    participant GJS as GrapesJS\n\n    Parent->>API: GET /api/page-blocks\n    API-->>Parent: PageBlock[]\n    Parent->>Editor: <GrapesJSEditor customBlocks={blocks} />\n    Editor->>Editor: useEffect(() => init)\n    Editor->>GJS: grapesjs.init()\n    GJS-->>Editor: editor instance\n    Editor->>Editor: Register custom blocks loop\n    loop For each block\n        Editor->>Editor: generateBlockHtml(type, defaults)\n        Editor->>GJS: BlockManager.add(id, config)\n    end\n    GJS-->>Editor: Blocks ready
"},{"location":"v2/features/pages/grapes-editor/#block-generation-logic","title":"Block Generation Logic","text":"
// From GrapesJSEditor.tsx\nconst blockManager = editor.Blocks;\nfor (const block of customBlocks) {\n  const defaults = block.defaults as Record<string, unknown>;\n  const html = generateBlockHtml(block.type, defaults);\n\n  blockManager.add(`custom-${block.type}`, {\n    label: block.label,\n    category: block.category || 'Campaign',\n    content: html,\n  });\n}\n

Example Block:

// From seed.ts\n{\n  id: 'default-hero',\n  type: 'hero',\n  label: 'Hero Section',\n  category: 'Headers',\n  defaults: {\n    title: 'Welcome to Our Campaign',\n    subtitle: 'Join us in making a difference.',\n    ctaText: 'Get Involved',\n    ctaUrl: '#',\n  },\n}\n

Generated HTML:

<section style=\"padding: 80px 40px; text-align: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: #fff;\">\n  <h1 style=\"font-size: 2.5rem; margin-bottom: 16px;\">Welcome to Our Campaign</h1>\n  <p style=\"font-size: 1.25rem; opacity: 0.85; margin-bottom: 32px;\">Join us in making a difference.</p>\n  <a href=\"#\" style=\"display: inline-block; padding: 12px 32px; background: #9d4edd; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600;\">Get Involved</a>\n</section>\n
"},{"location":"v2/features/pages/grapes-editor/#built-in-block-templates","title":"Built-In Block Templates","text":"

1. Hero Section

case 'hero':\n  return `\n    <section style=\"padding: 80px 40px; text-align: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: #fff;\">\n      <h1 style=\"font-size: 2.5rem; margin-bottom: 16px;\">${defaults.title || 'Hero Title'}</h1>\n      <p style=\"font-size: 1.25rem; opacity: 0.85; margin-bottom: 32px;\">${defaults.subtitle || 'Subtitle text here'}</p>\n      <a href=\"${defaults.ctaUrl || '#'}\" style=\"display: inline-block; padding: 12px 32px; background: #9d4edd; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600;\">${defaults.ctaText || 'Get Started'}</a>\n    </section>`;\n

2. Text Block

case 'text':\n  return `\n    <section style=\"padding: 60px 40px; max-width: 800px; margin: 0 auto;\">\n      <h2 style=\"font-size: 1.75rem; margin-bottom: 16px;\">${defaults.heading || 'Heading'}</h2>\n      <p style=\"font-size: 1rem; line-height: 1.7; opacity: 0.85;\">${defaults.body || 'Body text goes here.'}</p>\n    </section>`;\n

3. Features Grid

case 'features': {\n  const features = (defaults.features as Array<{ title: string; description: string }>) || [];\n  const featureHtml = features.map(f => `\n    <div style=\"flex: 1; min-width: 250px; padding: 24px; text-align: center;\">\n      <h3 style=\"font-size: 1.25rem; margin-bottom: 8px;\">${f.title}</h3>\n      <p style=\"opacity: 0.8;\">${f.description}</p>\n    </div>`).join('');\n\n  return `\n    <section style=\"padding: 60px 40px;\">\n      <div style=\"display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;\">\n        ${featureHtml}\n      </div>\n    </section>`;\n}\n

4. Call to Action

case 'cta':\n  return `\n    <section style=\"padding: 60px 40px; text-align: center; background: linear-gradient(135deg, #9d4edd 0%, #7b2cbf 100%); color: #fff;\">\n      <h2 style=\"font-size: 2rem; margin-bottom: 12px;\">${defaults.heading || 'Call to Action'}</h2>\n      <p style=\"font-size: 1.1rem; margin-bottom: 24px; opacity: 0.9;\">${defaults.description || 'Description here'}</p>\n      <a href=\"${defaults.buttonUrl || '#'}\" style=\"display: inline-block; padding: 12px 32px; background: #fff; color: #9d4edd; text-decoration: none; border-radius: 6px; font-weight: 600;\">${defaults.buttonText || 'Click Here'}</a>\n    </section>`;\n

5. Video Block

case 'video': {\n  const videoId = defaults.videoId || 'PLACEHOLDER';\n  const playerType = defaults.playerType || 'standard';\n\n  return `\n    <section style=\"padding: 60px 40px;\">\n      <div class=\"video-block\"\n           data-video-id=\"${videoId}\"\n           data-player-type=\"${playerType}\"\n           data-autoplay=\"${defaults.autoplay || false}\"\n           data-controls=\"${defaults.controls !== false}\"\n           data-show-reactions=\"${defaults.showReactions !== false}\"\n           style=\"max-width: 100%; margin: 0 auto;\">\n        <div class=\"video-placeholder\" style=\"aspect-ratio: 16/9; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; display: flex; align-items: center; justify-content: center;\">\n          <div style=\"text-align: center; color: #fff; padding: 24px;\">\n            <svg style=\"width: 64px; height: 64px; margin-bottom: 16px; opacity: 0.9;\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n              <path d=\"M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z\" />\n            </svg>\n            <p style=\"margin: 0; font-size: 1.1rem; font-weight: 600;\">Video Player</p>\n            <p style=\"margin: 8px 0 0; font-size: 0.9rem; opacity: 0.8;\">ID: ${videoId}</p>\n            <p style=\"margin: 4px 0 0; font-size: 0.85rem; opacity: 0.7;\">${playerType === 'advanced' ? 'Advanced Player (with reactions)' : 'Standard HTML5 Player'}</p>\n            <p style=\"margin: 12px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;\">Video will render on published page</p>\n          </div>\n        </div>\n      </div>\n    </section>`;\n}\n
"},{"location":"v2/features/pages/grapes-editor/#save-command-integration","title":"Save Command Integration","text":""},{"location":"v2/features/pages/grapes-editor/#command-registration","title":"Command Registration","text":"
// In useEffect() after editor init\neditor.Commands.add('save-page', {\n  run(ed: Editor) {\n    const projectData = ed.getProjectData() as Record<string, unknown>;\n    const html = ed.getHtml();\n    const css = ed.getCss() || '';\n    onSaveRef.current({ projectData, html, css });\n  },\n});\n

Why onSaveRef?

"},{"location":"v2/features/pages/grapes-editor/#keyboard-shortcut","title":"Keyboard Shortcut","text":"
const handleKeyDown = (e: KeyboardEvent) => {\n  if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n    e.preventDefault();\n    editor.runCommand('save-page');\n  }\n};\n\ndocument.addEventListener('keydown', handleKeyDown);\n\n// Cleanup\nreturn () => {\n  document.removeEventListener('keydown', handleKeyDown);\n  editor.destroy();\n};\n

Shortcuts:

Behavior:

"},{"location":"v2/features/pages/grapes-editor/#forwardref-pattern","title":"forwardRef Pattern","text":""},{"location":"v2/features/pages/grapes-editor/#implementation","title":"Implementation","text":"
const GrapesJSEditor = forwardRef<GrapesJSEditorHandle, GrapesJSEditorProps>(\n  function GrapesJSEditor({ initialData, onSave, customBlocks }, ref) {\n    const editorRef = useRef<Editor | null>(null);\n\n    useImperativeHandle(ref, () => ({\n      triggerSave() {\n        editorRef.current?.runCommand('save-page');\n      },\n    }));\n\n    // ... rest of component\n  }\n);\n
"},{"location":"v2/features/pages/grapes-editor/#parent-usage","title":"Parent Usage","text":"
// In LandingPageEditor.tsx\nimport { useRef } from 'react';\n\nconst editorRef = useRef<GrapesJSEditorHandle>(null);\n\nconst handleManualSave = () => {\n  editorRef.current?.triggerSave(); // Programmatic save\n};\n\nreturn (\n  <div>\n    <button onClick={handleManualSave}>Save</button>\n    <GrapesJSEditor ref={editorRef} onSave={handleSave} />\n  </div>\n);\n

Why forwardRef?

"},{"location":"v2/features/pages/grapes-editor/#error-handling","title":"Error Handling","text":""},{"location":"v2/features/pages/grapes-editor/#error-boundary-state","title":"Error Boundary State","text":"
const [error, setError] = useState<string | null>(null);\n\ntry {\n  editor = grapesjs.init({ /* ... */ });\n} catch (err) {\n  console.error('GrapesJS init error:', err);\n  setError('Failed to initialize the page editor. Please refresh the page.');\n  return;\n}\n\nif (error) {\n  return (\n    <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ff4d4f' }}>\n      {error}\n    </div>\n  );\n}\n

Failure Modes:

  1. Missing plugin: GrapesJS throws error during init()
  2. Browser incompatibility: Old browser doesn't support ES6 modules
  3. Memory exhaustion: Very large initialData crashes tab

Recovery:

"},{"location":"v2/features/pages/grapes-editor/#parent-level-fallback","title":"Parent-Level Fallback","text":"
// In LandingPageEditor.tsx\nimport { ErrorBoundary } from 'react-error-boundary';\n\n<ErrorBoundary\n  fallback={<div>Editor failed to load. Please try CODE mode.</div>}\n  onReset={() => navigate('/app/pages')}\n>\n  <GrapesJSEditor ref={editorRef} onSave={handleSave} />\n</ErrorBoundary>\n

Cascade:

  1. GrapesJS init error \u2192 Internal error state
  2. React render error \u2192 ErrorBoundary catches
  3. User sees fallback \u2192 Can switch to CODE mode
"},{"location":"v2/features/pages/grapes-editor/#mobile-detection","title":"Mobile Detection","text":""},{"location":"v2/features/pages/grapes-editor/#desktop-only-warning","title":"Desktop-Only Warning","text":"

Location: LandingPageEditor.tsx (parent component)

import { Grid } from 'antd';\n\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md; // md = 768px\n\nif (isMobile) {\n  return (\n    <Result\n      status=\"warning\"\n      title=\"Desktop Required\"\n      subTitle=\"The page editor requires a desktop or tablet device (minimum 768px width).\"\n      extra={<Button onClick={() => navigate('/app/pages')}>Back to Pages</Button>}\n    />\n  );\n}\n

Why desktop-only?

Alternative for mobile admins:

"},{"location":"v2/features/pages/grapes-editor/#data-flow-patterns","title":"Data Flow Patterns","text":""},{"location":"v2/features/pages/grapes-editor/#initial-load","title":"Initial Load","text":"
sequenceDiagram\n    participant DB as Database\n    participant API as API Service\n    participant Parent as LandingPageEditor\n    participant Editor as GrapesJSEditor\n    participant GJS as GrapesJS\n\n    Parent->>API: GET /api/pages/:id\n    API->>DB: SELECT blocks FROM landing_pages\n    DB-->>API: { blocks: {...} }\n    API-->>Parent: LandingPage JSON\n    Parent->>Editor: <GrapesJSEditor initialData={page.blocks} />\n    Editor->>GJS: editor.loadProjectData(initialData)\n    GJS-->>Editor: Canvas rendered

Key Points:

"},{"location":"v2/features/pages/grapes-editor/#save-flow","title":"Save Flow","text":"
sequenceDiagram\n    participant User as User\n    participant Parent as LandingPageEditor\n    participant Editor as GrapesJSEditor\n    participant GJS as GrapesJS\n    participant API as API\n\n    User->>User: Press Ctrl+S\n    User->>Editor: KeyboardEvent\n    Editor->>GJS: runCommand('save-page')\n    GJS->>GJS: getProjectData()\n    GJS->>GJS: getHtml()\n    GJS->>GJS: getCss()\n    GJS-->>Editor: { projectData, html, css }\n    Editor->>Parent: onSave(data)\n    Parent->>API: PUT /api/pages/:id\n    API-->>Parent: 200 OK\n    Parent->>User: \"Page saved\" notification

Critical Detail:

"},{"location":"v2/features/pages/grapes-editor/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/pages/grapes-editor/#complete-integration-example","title":"Complete Integration Example","text":"
// admin/src/pages/LandingPageEditor.tsx\nimport { useState, useEffect, useRef } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { Button, message, Spin } from 'antd';\nimport { api } from '@/lib/api';\nimport GrapesJSEditor, { GrapesJSEditorHandle } from '@/components/GrapesJSEditor';\nimport type { LandingPage, PageBlock } from '@/types/api';\n\ninterface LandingPageEditorProps {\n  pageId: string;\n  onClose: () => void;\n}\n\nexport default function LandingPageEditor({ pageId, onClose }: LandingPageEditorProps) {\n  const navigate = useNavigate();\n  const editorRef = useRef<GrapesJSEditorHandle>(null);\n  const [page, setPage] = useState<LandingPage | null>(null);\n  const [blocks, setBlocks] = useState<PageBlock[]>([]);\n  const [loading, setLoading] = useState(true);\n\n  useEffect(() => {\n    const fetchData = async () => {\n      try {\n        const [pageRes, blocksRes] = await Promise.all([\n          api.get<LandingPage>(`/pages/${pageId}`),\n          api.get<PageBlock[]>('/page-blocks'),\n        ]);\n        setPage(pageRes.data);\n        setBlocks(blocksRes.data);\n      } catch {\n        message.error('Failed to load page');\n        onClose();\n      } finally {\n        setLoading(false);\n      }\n    };\n    fetchData();\n  }, [pageId, onClose]);\n\n  const handleSave = async (data: { projectData: any; html: string; css: string }) => {\n    try {\n      await api.put(`/pages/${pageId}`, {\n        blocks: data.projectData,\n        htmlOutput: data.html,\n        cssOutput: data.css,\n      });\n      message.success('Page saved');\n    } catch {\n      message.error('Failed to save page');\n    }\n  };\n\n  const handleManualSave = () => {\n    editorRef.current?.triggerSave();\n  };\n\n  if (loading) return <Spin size=\"large\" />;\n  if (!page) return null;\n\n  return (\n    <div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>\n      <div style={{ padding: '12px 16px', borderBottom: '1px solid #d9d9d9' }}>\n        <Button onClick={onClose}>Back</Button>\n        <Button type=\"primary\" onClick={handleManualSave} style={{ marginLeft: 8 }}>\n          Save (Ctrl+S)\n        </Button>\n      </div>\n      <GrapesJSEditor\n        ref={editorRef}\n        initialData={page.blocks as Record<string, unknown>}\n        onSave={handleSave}\n        customBlocks={blocks}\n      />\n    </div>\n  );\n}\n
"},{"location":"v2/features/pages/grapes-editor/#custom-block-registration","title":"Custom Block Registration","text":"
// Add a custom \"Campaign Stats\" block\nconst campaignStatsBlock: PageBlock = {\n  id: 'custom-campaign-stats',\n  type: 'campaign-stats',\n  label: 'Campaign Stats',\n  category: 'Campaign',\n  sortOrder: 10,\n  schema: {\n    volunteers: { type: 'number', label: 'Volunteers' },\n    emails: { type: 'number', label: 'Emails Sent' },\n    events: { type: 'number', label: 'Events' },\n  },\n  defaults: {\n    volunteers: 1250,\n    emails: 5400,\n    events: 32,\n  },\n};\n\n// GrapesJSEditor will auto-register via generateBlockHtml()\n<GrapesJSEditor customBlocks={[campaignStatsBlock, ...otherBlocks]} />\n
"},{"location":"v2/features/pages/grapes-editor/#adding-custom-html-generation","title":"Adding Custom HTML Generation","text":"
// In GrapesJSEditor.tsx generateBlockHtml() function\ncase 'campaign-stats': {\n  const volunteers = defaults.volunteers || 0;\n  const emails = defaults.emails || 0;\n  const events = defaults.events || 0;\n\n  return `\n    <section style=\"padding: 60px 40px; background: #f8f9fa; text-align: center;\">\n      <h2 style=\"margin-bottom: 32px; font-size: 2rem;\">Our Impact</h2>\n      <div style=\"display: flex; gap: 48px; justify-content: center; flex-wrap: wrap;\">\n        <div>\n          <div style=\"font-size: 3rem; font-weight: 700; color: #9d4edd;\">${volunteers.toLocaleString()}</div>\n          <div style=\"font-size: 1rem; color: #666;\">Volunteers</div>\n        </div>\n        <div>\n          <div style=\"font-size: 3rem; font-weight: 700; color: #9d4edd;\">${emails.toLocaleString()}</div>\n          <div style=\"font-size: 1rem; color: #666;\">Emails Sent</div>\n        </div>\n        <div>\n          <div style=\"font-size: 3rem; font-weight: 700; color: #9d4edd;\">${events}</div>\n          <div style=\"font-size: 1rem; color: #666;\">Events</div>\n        </div>\n      </div>\n    </section>`;\n}\n
"},{"location":"v2/features/pages/grapes-editor/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/pages/grapes-editor/#problem-blocks-not-appearing-in-left-panel","title":"Problem: Blocks Not Appearing in Left Panel","text":"

Symptoms:

Causes:

  1. generateBlockHtml() missing case for block type
  2. Category name mismatch
  3. Block registration timing issue

Solutions:

  1. Add case to generateBlockHtml():

    case 'my-custom-block':\n  return `<section>My custom block HTML</section>`;\n

  2. Check category:

    // Block category: \"Campaign\"\n// GrapesJS shows blocks in collapsible \"Campaign\" section\n// Case-sensitive match\n

  3. Verify registration timing:

    // Registration happens in useEffect after init\nconsole.log('Registering blocks:', customBlocks.length);\n

  4. Inspect BlockManager:

    // In browser console (after editor loads)\nwindow.editor.BlockManager.getAll().forEach(b => console.log(b.id));\n// Should include 'custom-hero', 'custom-text', etc.\n

"},{"location":"v2/features/pages/grapes-editor/#problem-save-not-triggering","title":"Problem: Save Not Triggering","text":"

Symptoms:

Causes:

  1. Keyboard event listener not registered
  2. forwardRef not working
  3. save-page command not registered

Solutions:

  1. Check keyboard listener:

    // In GrapesJSEditor useEffect\nconst handleKeyDown = (e: KeyboardEvent) => {\n  console.log('Key pressed:', e.key, 'Ctrl:', e.ctrlKey);\n  if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n    console.log('Save shortcut triggered');\n    e.preventDefault();\n    editor.runCommand('save-page');\n  }\n};\n

  2. Verify ref handle:

    // In parent component\nconsole.log('Editor ref:', editorRef.current); // Should be { triggerSave: fn }\n

  3. Test command directly:

    // In browser console (after editor loads)\nwindow.editor.runCommand('save-page');\n// Should trigger onSave callback\n

  4. Check onSaveRef pattern:

    const onSaveRef = useRef(onSave);\nonSaveRef.current = onSave; // Update on every render\n

"},{"location":"v2/features/pages/grapes-editor/#problem-editor-crashes-on-large-pages","title":"Problem: Editor Crashes on Large Pages","text":"

Symptoms:

Causes:

Solutions:

  1. Split into multiple pages:
  2. Separate hero, features, testimonials into 3 pages
  3. Link pages via navigation

  4. Use CODE mode for complex layouts:

  5. Write HTML directly \u2192 Faster than GrapesJS rendering
  6. Import via \"Sync Overrides\"

  7. Optimize images:

  8. Use external CDN (not base64-encoded)
  9. Compress before upload
  10. Lazy load below fold

  11. Increase browser memory:

  12. Chrome \u2192 --max-old-space-size=4096
  13. Edge \u2192 Similar flag
"},{"location":"v2/features/pages/grapes-editor/#problem-initial-data-not-loading","title":"Problem: Initial Data Not Loading","text":"

Symptoms:

Causes:

  1. loadProjectData() called before editor ready
  2. Invalid JSON structure
  3. Async timing issue

Solutions:

  1. Check editor ready state:

    useEffect(() => {\n  if (!containerRef.current) return;\n\n  const editor = grapesjs.init({ /* ... */ });\n\n  // Wait for editor load event\n  editor.on('load', () => {\n    if (initialData && Object.keys(initialData).length > 0) {\n      editor.loadProjectData(initialData);\n    }\n  });\n}, []);\n

  2. Validate JSON:

    console.log('Loading data:', JSON.stringify(initialData, null, 2));\n// Should have keys: assets, styles, pages\n

  3. Handle empty data:

    if (initialData && Object.keys(initialData).length > 0) {\n  editor.loadProjectData(initialData);\n} else {\n  console.log('Starting with blank canvas');\n}\n

"},{"location":"v2/features/pages/grapes-editor/#problem-styles-not-applying-in-canvas","title":"Problem: Styles Not Applying in Canvas","text":"

Symptoms:

Causes:

  1. Inline styles not supported
  2. External stylesheet missing
  3. Canvas iframe CSP issue

Solutions:

  1. Use inline styles in generateBlockHtml():

    // Good\nreturn `<section style=\"padding: 40px; background: #f00;\">...</section>`;\n\n// Bad (requires CSS injection)\nreturn `<section class=\"hero\">...</section>`;\n

  2. Inject fonts into canvas:

    canvas: {\n  styles: [\n    'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',\n  ],\n}\n

  3. Check iframe sandbox:

    // GrapesJS canvas uses <iframe> \u2014 ensure no sandbox restrictions\n// Default config works, but custom CSP may block\n

"},{"location":"v2/features/pages/grapes-editor/#performance-optimization","title":"Performance Optimization","text":""},{"location":"v2/features/pages/grapes-editor/#lazy-loading","title":"Lazy Loading","text":"
// In LandingPageEditor.tsx\nimport { lazy, Suspense } from 'react';\n\nconst GrapesJSEditor = lazy(() => import('@/components/GrapesJSEditor'));\n\nreturn (\n  <Suspense fallback={<Spin size=\"large\" />}>\n    <GrapesJSEditor ref={editorRef} onSave={handleSave} />\n  </Suspense>\n);\n

Benefit: Reduces initial bundle size by ~800KB (GrapesJS + plugins)

"},{"location":"v2/features/pages/grapes-editor/#debounced-auto-save","title":"Debounced Auto-Save","text":"
import { useRef, useEffect } from 'react';\n\nconst autoSaveTimerRef = useRef<ReturnType<typeof setTimeout>>();\n\nconst handleEditorChange = () => {\n  clearTimeout(autoSaveTimerRef.current);\n  autoSaveTimerRef.current = setTimeout(() => {\n    editorRef.current?.triggerSave();\n  }, 5000); // Auto-save after 5s of inactivity\n};\n\nuseEffect(() => {\n  // Listen to editor change events\n  const editor = window.editor; // Access via global (not recommended for prod)\n  editor?.on('component:update', handleEditorChange);\n  editor?.on('style:update', handleEditorChange);\n\n  return () => {\n    clearTimeout(autoSaveTimerRef.current);\n    editor?.off('component:update', handleEditorChange);\n    editor?.off('style:update', handleEditorChange);\n  };\n}, []);\n

Trade-off: More API calls vs. reduced data loss risk

"},{"location":"v2/features/pages/grapes-editor/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/pages/grapes-editor/#components","title":"Components","text":""},{"location":"v2/features/pages/grapes-editor/#features","title":"Features","text":""},{"location":"v2/features/pages/grapes-editor/#external","title":"External","text":""},{"location":"v2/features/pages/mkdocs-export/","title":"MkDocs Export Integration","text":"

Export landing pages to MkDocs Material theme with Jinja2 template wrapping, front matter configuration, and synchronized stub files.

"},{"location":"v2/features/pages/mkdocs-export/#overview","title":"Overview","text":"

The MkDocs Export system bridges the Page Builder and static documentation site. Administrators can publish landing pages to the main MkDocs site, where they benefit from Material theme styling, navigation, and SEO features.

"},{"location":"v2/features/pages/mkdocs-export/#key-features","title":"Key Features","text":""},{"location":"v2/features/pages/mkdocs-export/#architecture","title":"Architecture","text":"
graph TD\n    A[Admin] -->|Publish Page| B[PUT /api/pages/:id]\n    B --> C[pages.service.update]\n    C --> D{published && !skipExport?}\n    D -->|Yes| E[exportToMkDocs]\n    E --> F[wrapInMaterialOverride]\n    E --> G[generateMdStub]\n    F --> H[Write .html to overrides/]\n    G --> I[Write .md to docs/]\n\n    J[MkDocs Build] --> K[Read .md stub]\n    K --> L[Front matter: template]\n    L --> H\n    H --> M[Render with Material theme]\n    M --> N[Public site]\n\n    style E fill:#9d4edd\n    style H fill:#3498db\n    style N fill:#2ecc71

Flow:

  1. Trigger: Admin publishes page (or updates published page)
  2. Service: pages.service.update() checks publish status
  3. Export: Calls exportToMkDocs() with page data
  4. Wrap: HTML wrapped in Jinja2 {% extends \"main.html\" %}
  5. Write: Two files created:
  6. mkdocs/docs/overrides/{slug}.html \u2014 HTML override
  7. mkdocs/docs/{slug}.md \u2014 Markdown stub
  8. Build: MkDocs rebuild (mkdocs build)
  9. Render: Stub references override, Material theme applies
  10. Serve: Page accessible at https://cmlite.org/pages/{slug}/
"},{"location":"v2/features/pages/mkdocs-export/#export-modes","title":"Export Modes","text":""},{"location":"v2/features/pages/mkdocs-export/#themed-mode-default","title":"THEMED Mode (Default)","text":"

Purpose: Integrate page with MkDocs Material theme (header, footer, navigation)

Jinja2 Template:

{% extends \"main.html\" %}\n{% block content %}\n<style>\nsection { padding: 40px; }\n</style>\n<section>\n  <h1>Welcome</h1>\n  <p>Page content here.</p>\n</section>\n{% endblock %}\n

Features:

Use Cases:

"},{"location":"v2/features/pages/mkdocs-export/#standalone-mode","title":"STANDALONE Mode","text":"

Purpose: Full control over HTML (no MkDocs chrome)

HTML Document:

<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>About Us | Campaign 2026</title>\n    <meta name=\"description\" content=\"Join our movement for change.\">\n    <style>\n    section { padding: 40px; }\n    </style>\n</head>\n<body>\n<section>\n  <h1>Welcome</h1>\n  <p>Page content here.</p>\n</section>\n</body>\n</html>\n

Features:

Use Cases:

"},{"location":"v2/features/pages/mkdocs-export/#file-outputs","title":"File Outputs","text":""},{"location":"v2/features/pages/mkdocs-export/#override-file-html","title":"Override File (.html)","text":"

Location: mkdocs/docs/overrides/{slug}.html

Example: mkdocs/docs/overrides/about-us.html

Content (THEMED mode):

{% extends \"main.html\" %}\n{% block content %}\n<style>\n/* Page CSS */\n</style>\n<!-- Page HTML -->\n{% endblock %}\n

Content (STANDALONE mode):

<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <title>About Us</title>\n  <style>/* Page CSS */</style>\n</head>\n<body>\n  <!-- Page HTML -->\n</body>\n</html>\n

Access Control:

"},{"location":"v2/features/pages/mkdocs-export/#stub-file-md","title":"Stub File (.md)","text":"

Location: mkdocs/docs/{slug}.md

Example: mkdocs/docs/about-us.md

Content:

---\ntemplate: about-us.html\ntitle: \"About Us | Campaign 2026\"\ndescription: \"Join our movement for change.\"\nhide:\n  - navigation\n  - toc\n---\n

Front Matter Fields:

Field Type Description template string Override filename (relative to custom_dir) title string Page title (from seoTitle or title) description string Meta description (from seoDescription) hide array Hide navigation/toc elements

Important: Template path is relative to custom_dir (mkdocs/overrides/). Use about-us.html, NOT overrides/about-us.html (causes TemplateNotFound error).

"},{"location":"v2/features/pages/mkdocs-export/#database-fields","title":"Database Fields","text":""},{"location":"v2/features/pages/mkdocs-export/#landingpage-export-configuration","title":"LandingPage Export Configuration","text":"

Fields:

Field Type Default Description mkdocsPath String? {slug}.html Override filename (auto-generated from slug) mkdocsStubPath String? {slug}.md Stub filename (derived from mkdocsPath) mkdocsExportMode Enum THEMED THEMED or STANDALONE mkdocsHideNav Boolean false Hide navigation sidebar (THEMED only) mkdocsHideToc Boolean false Hide table of contents (THEMED only) mkdocsSkipExport Boolean false Skip MkDocs export entirely

Behavior:

"},{"location":"v2/features/pages/mkdocs-export/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/pages/mkdocs-export/#exporting-a-page","title":"Exporting a Page","text":"

Automatic Export (on publish):

  1. Admin \u2192 Pages \u2192 Click \"Publish\" button
  2. API updates published=true
  3. Service checks mkdocsSkipExport
  4. If false: Calls exportToMkDocs()
  5. Files written to disk
  6. Database updated with mkdocsStubPath

Manual Export Trigger:

  1. Edit page settings
  2. Change mkdocsExportMode or mkdocsHideNav
  3. Save settings
  4. If published: Auto re-exports
"},{"location":"v2/features/pages/mkdocs-export/#configuring-export-options","title":"Configuring Export Options","text":"

Location: Page Settings modal \u2192 MkDocs Integration section

Steps:

  1. Admin \u2192 Pages \u2192 Click gear icon (Settings)
  2. Scroll to \"MkDocs Integration\"
  3. Configure options:
  4. Skip MkDocs Export: \u2610 (unchecked)
  5. Override Path: about.html (auto-filled)
  6. Full page MkDocs: \u2610 (THEMED mode)
  7. Hide navigation sidebar: \u2611 (checked)
  8. Hide table of contents: \u2611 (checked)
  9. Click \"Save\"
  10. If published: Files re-exported immediately
"},{"location":"v2/features/pages/mkdocs-export/#rebuilding-mkdocs-site","title":"Rebuilding MkDocs Site","text":"

Trigger: After exporting pages

Methods:

Option 1: Admin UI

  1. Admin \u2192 Pages \u2192 \"Build Site\" button (SUPER_ADMIN only)
  2. Confirmation modal appears
  3. Click \"Confirm\"
  4. API executes docker compose exec mkdocs mkdocs build
  5. Success notification

Option 2: Command Line

docker compose exec mkdocs mkdocs build\n# Rebuilds site from mkdocs/docs/ directory\n# Output: mkdocs/site/ (static HTML)\n

Auto-rebuild: Not implemented (manual trigger required)

"},{"location":"v2/features/pages/mkdocs-export/#syncing-overrides","title":"Syncing Overrides","text":"

Purpose: Import hand-coded .html files from overrides/ directory

Workflow:

  1. Place .html file in mkdocs/docs/overrides/custom.html
  2. Admin \u2192 Pages \u2192 \"Sync Overrides\" button
  3. API scans directory:
  4. Untracked files \u2192 Create CODE-mode page
  5. Tracked CODE-mode pages \u2192 Update htmlOutput from disk
  6. VISUAL pages \u2192 Skip (managed by GrapesJS)
  7. Backfills missing .md stubs
  8. Shows result: Synced: 2 imported, 1 updated, 3 stubs created

Use Cases:

"},{"location":"v2/features/pages/mkdocs-export/#validating-exports","title":"Validating Exports","text":"

Purpose: Verify files exist on disk, repair if missing

Workflow:

  1. Admin \u2192 Pages \u2192 \"Validate Exports\" button
  2. API queries all published, non-skipped pages
  3. For each page:
  4. Check .html override exists
  5. Check .md stub exists
  6. If either missing: Re-export
  7. Shows result: Validated 10 pages: 2 repaired, 0 errors

Use Cases:

"},{"location":"v2/features/pages/mkdocs-export/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/pages/mkdocs-export/#themed-mode-export","title":"Themed Mode Export","text":"
// From pages.service.ts\n\nfunction wrapInMaterialOverride(html: string, css: string | null): string {\n  const styleBlock = css ? `<style>\\n${css}\\n</style>` : '';\n  return `{% extends \"main.html\" %}\n{% block content %}\n${styleBlock}\n${html}\n{% endblock %}\n`;\n}\n\n// Usage\nconst content = wrapInMaterialOverride(\n  '<section><h1>About Us</h1></section>',\n  'section { padding: 40px; }'\n);\n\n// Result:\n// {% extends \"main.html\" %}\n// {% block content %}\n// <style>\n// section { padding: 40px; }\n// </style>\n// <section><h1>About Us</h1></section>\n// {% endblock %}\n
"},{"location":"v2/features/pages/mkdocs-export/#standalone-mode-export","title":"Standalone Mode Export","text":"
function wrapInStandaloneDocument(\n  html: string,\n  css: string | null,\n  title: string,\n  description: string | null\n): string {\n  const metaDesc = description\n    ? `\\n    <meta name=\"description\" content=\"${description.replace(/\"/g, '&quot;')}\">`\n    : '';\n  const styleBlock = css ? `\\n    <style>\\n${css}\\n    </style>` : '';\n\n  return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>${title.replace(/</g, '&lt;')}</title>${metaDesc}${styleBlock}\n</head>\n<body>\n${html}\n</body>\n</html>\n`;\n}\n\n// Usage\nconst content = wrapInStandaloneDocument(\n  '<section><h1>About Us</h1></section>',\n  'section { padding: 40px; }',\n  'About Us | Campaign 2026',\n  'Join our movement for change.'\n);\n
"},{"location":"v2/features/pages/mkdocs-export/#markdown-stub-generation","title":"Markdown Stub Generation","text":"
interface StubOptions {\n  overrideFilename: string;\n  title: string;\n  description: string | null;\n  hideNav: boolean;\n  hideToc: boolean;\n}\n\nfunction generateMdStub(opts: StubOptions): string {\n  const hideItems: string[] = [];\n  if (opts.hideNav) hideItems.push('  - navigation');\n  if (opts.hideToc) hideItems.push('  - toc');\n\n  const hideBlock = hideItems.length > 0\n    ? `hide:\\n${hideItems.join('\\n')}\\n`\n    : '';\n\n  const descLine = opts.description\n    ? `description: \"${opts.description.replace(/\"/g, '\\\\\"')}\"\\n`\n    : '';\n\n  return `---\ntemplate: ${opts.overrideFilename}\n${hideBlock}title: \"${opts.title.replace(/\"/g, '\\\\\"')}\"\n${descLine}---\n`;\n}\n\n// Usage\nconst stub = generateMdStub({\n  overrideFilename: 'about-us.html',\n  title: 'About Us | Campaign 2026',\n  description: 'Join our movement.',\n  hideNav: true,\n  hideToc: true,\n});\n\n// Result:\n// ---\n// template: about-us.html\n// hide:\n//   - navigation\n//   - toc\n// title: \"About Us | Campaign 2026\"\n// description: \"Join our movement.\"\n// ---\n
"},{"location":"v2/features/pages/mkdocs-export/#export-orchestration","title":"Export Orchestration","text":"
// From pages.service.update()\n\n// After updating page in database\nif (page.published && !page.mkdocsSkipExport && page.mkdocsPath && page.htmlOutput) {\n  const stubPath = await exportToMkDocs({\n    mkdocsPath: page.mkdocsPath,\n    html: page.htmlOutput,\n    css: page.cssOutput,\n    editorMode: page.editorMode,\n    exportMode: page.mkdocsExportMode,\n    title: page.title,\n    seoTitle: page.seoTitle,\n    seoDescription: page.seoDescription,\n    hideNav: page.mkdocsHideNav,\n    hideToc: page.mkdocsHideToc,\n  });\n\n  // Store stubPath if changed\n  if (stubPath !== page.mkdocsStubPath) {\n    await prisma.landingPage.update({\n      where: { id },\n      data: { mkdocsStubPath: stubPath },\n    });\n  }\n} else if ((!page.published || page.mkdocsSkipExport) && existing.mkdocsPath) {\n  // Clean up exports on unpublish or skip\n  await removeFromMkDocs(existing.mkdocsPath, existing.mkdocsStubPath);\n\n  if (existing.mkdocsStubPath) {\n    await prisma.landingPage.update({\n      where: { id },\n      data: { mkdocsStubPath: null },\n    });\n  }\n}\n
"},{"location":"v2/features/pages/mkdocs-export/#path-validation","title":"Path Validation","text":"
function validateMkdocsPath(mkdocsPath: string): void {\n  // Check for null bytes\n  if (mkdocsPath.includes('\\0')) {\n    throw new AppError(400, 'Invalid path: null byte detected', 'INVALID_MKDOCS_PATH');\n  }\n\n  // Normalize and check for traversal\n  const normalized = path.normalize(mkdocsPath);\n  if (normalized.includes('..') || path.isAbsolute(normalized)) {\n    throw new AppError(400, 'Path traversal not allowed', 'INVALID_MKDOCS_PATH');\n  }\n\n  // Check for encoded traversal sequences\n  if (mkdocsPath.includes('%2e') || mkdocsPath.includes('%2E')) {\n    throw new AppError(400, 'Encoded path traversal not allowed', 'INVALID_MKDOCS_PATH');\n  }\n\n  if (!mkdocsPath.endsWith('.html')) {\n    throw new AppError(400, 'Path must end with .html', 'INVALID_MKDOCS_PATH');\n  }\n}\n\n// Safe paths\nvalidateMkdocsPath('about.html'); // \u2713\nvalidateMkdocsPath('pages/contact.html'); // \u2713\n\n// Rejected paths\nvalidateMkdocsPath('../etc/passwd.html'); // \u2717 Path traversal\nvalidateMkdocsPath('/etc/shadow.html'); // \u2717 Absolute path\nvalidateMkdocsPath('admin%2e%2e/config.html'); // \u2717 Encoded traversal\nvalidateMkdocsPath('about.md'); // \u2717 Missing .html extension\n
"},{"location":"v2/features/pages/mkdocs-export/#mkdocs-configuration","title":"MkDocs Configuration","text":""},{"location":"v2/features/pages/mkdocs-export/#mkdocsyml-settings","title":"mkdocs.yml Settings","text":"

Required Configuration:

site_name: Changemaker Lite\ntheme:\n  name: material\n  custom_dir: overrides  # Points to mkdocs/docs/overrides/\n\nnav:\n  - Home: index.md\n  - Pages:\n    - About: about-us.md\n    - Contact: contact.md\n

Key Points:

"},{"location":"v2/features/pages/mkdocs-export/#template-search-paths","title":"Template Search Paths","text":"

MkDocs Material searches:

  1. mkdocs/overrides/ (custom_dir)
  2. Material theme templates
  3. MkDocs core templates

Resolution:

# In stub front matter\ntemplate: about-us.html\n\n# MkDocs searches:\n# 1. mkdocs/overrides/about-us.html \u2713 (found here)\n# 2. material/templates/about-us.html\n# 3. mkdocs/templates/about-us.html\n

Common Mistake:

# WRONG - causes TemplateNotFound\ntemplate: overrides/about-us.html\n\n# MkDocs searches:\n# 1. mkdocs/overrides/overrides/about-us.html \u2717 (not found)\n

Solution: Use filename only, not path with overrides/.

"},{"location":"v2/features/pages/mkdocs-export/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/pages/mkdocs-export/#problem-template-not-found-error","title":"Problem: Template Not Found Error","text":"

Symptoms:

Causes:

  1. Stub uses template: overrides/about-us.html (incorrect path)
  2. custom_dir not configured in mkdocs.yml
  3. Override file doesn't exist

Solutions:

  1. Fix stub front matter:

    # Before (wrong)\ntemplate: overrides/about-us.html\n\n# After (correct)\ntemplate: about-us.html\n

  2. Verify custom_dir:

    # In mkdocs.yml\ntheme:\n  name: material\n  custom_dir: overrides\n

  3. Check file exists:

    ls -la mkdocs/docs/overrides/about-us.html\n# Should exist if page published\n

  4. Validate exports:

  5. Admin \u2192 Pages \u2192 \"Validate Exports\"
  6. Repairs missing files
"},{"location":"v2/features/pages/mkdocs-export/#problem-export-files-missing-after-restart","title":"Problem: Export Files Missing After Restart","text":"

Symptoms:

Causes:

  1. Volume mount not configured
  2. Files written to container filesystem (not host)
  3. Container recreated (ephemeral storage lost)

Solutions:

  1. Check volume mount:

    # In docker-compose.yml\nservices:\n  api:\n    volumes:\n      - ./mkdocs:/mkdocs:rw  # Must have :rw for write access\n

  2. Verify host files:

    ls -la mkdocs/docs/overrides/\n# Files should persist on host filesystem\n

  3. Re-export all pages:

  4. Admin \u2192 Pages \u2192 \"Validate Exports\"
  5. Regenerates all missing files
"},{"location":"v2/features/pages/mkdocs-export/#problem-page-not-appearing-in-mkdocs-site","title":"Problem: Page Not Appearing in MkDocs Site","text":"

Symptoms:

Causes:

  1. Stub not listed in mkdocs.yml nav
  2. MkDocs not rebuilt after export
  3. Nginx cache serving old version

Solutions:

  1. Add to nav (optional):

    nav:\n  - Pages:\n    - About: about-us.md  # Stub filename\n

  2. Rebuild MkDocs:

    docker compose exec mkdocs mkdocs build\n# Or Admin \u2192 Pages \u2192 \"Build Site\"\n

  3. Clear Nginx cache:

    docker compose exec nginx nginx -s reload\n

  4. Test direct access:

    curl http://localhost:4001/pages/about-us/\n# Should return HTML, not 404\n

"},{"location":"v2/features/pages/mkdocs-export/#problem-styles-not-applying-in-mkdocs","title":"Problem: Styles Not Applying in MkDocs","text":"

Symptoms:

Causes:

  1. CSS not exported (CODE mode without cssOutput)
  2. Material theme CSS conflicts
  3. Inline styles overridden

Solutions:

  1. Check cssOutput field:

    SELECT css_output FROM landing_pages WHERE slug = 'about-us';\n-- Should contain CSS, not NULL\n

  2. Inspect rendered HTML:

    curl http://localhost:4001/pages/about-us/ | grep '<style>'\n# Should include page CSS\n

  3. Use !important for overrides:

    /* In page CSS */\nsection {\n  padding: 40px !important;\n}\n

  4. Test STANDALONE mode:

  5. Settings \u2192 Full page MkDocs (checked)
  6. Bypasses Material theme CSS
"},{"location":"v2/features/pages/mkdocs-export/#problem-hide-navigation-not-working","title":"Problem: Hide Navigation Not Working","text":"

Symptoms:

Causes:

  1. Stub front matter not updated
  2. MkDocs cache not cleared
  3. STANDALONE mode enabled (hide options ignored)

Solutions:

  1. Check stub front matter:

    cat mkdocs/docs/about-us.md\n# Should have:\n# hide:\n#   - navigation\n

  2. Re-export:

  3. Edit page settings \u2192 Save
  4. Triggers stub regeneration

  5. Clear MkDocs cache:

    rm -rf mkdocs/site/\ndocker compose exec mkdocs mkdocs build\n

  6. Verify not STANDALONE:

  7. Settings \u2192 Full page MkDocs (unchecked)
  8. STANDALONE ignores hide options
"},{"location":"v2/features/pages/mkdocs-export/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/pages/mkdocs-export/#file-system-io","title":"File System I/O","text":"

Export operation: Writes 2 files per page (~1ms each)

Bottleneck: Synchronous file writes in API request handler

Impact:

Optimization (future):

// Current: Synchronous writes in request\nawait fs.writeFile(path, content);\n\n// Future: Background job queue\nawait queue.add('export-page', { pageId });\n
"},{"location":"v2/features/pages/mkdocs-export/#mkdocs-build-time","title":"MkDocs Build Time","text":"

Build duration: Proportional to page count

Optimization:

"},{"location":"v2/features/pages/mkdocs-export/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/features/pages/mkdocs-export/#path-traversal-protection","title":"Path Traversal Protection","text":"

Validation:

  1. Null byte check: Prevents about\\0.html attacks
  2. Normalization: path.normalize() resolves ../
  3. Absolute path check: Rejects /etc/passwd.html
  4. Encoded traversal: Blocks %2e%2e/admin.html
  5. Extension validation: Must end with .html

Rejected Paths:

"},{"location":"v2/features/pages/mkdocs-export/#file-permission-isolation","title":"File Permission Isolation","text":"

Docker Volume Mount:

volumes:\n  - ./mkdocs:/mkdocs:rw\n

Permissions:

Risk: Container escape could write arbitrary files

Mitigation:

"},{"location":"v2/features/pages/mkdocs-export/#template-injection","title":"Template Injection","text":"

Risk: Malicious admin injects Jinja2 code

Example:

<!-- Malicious HTML in editor -->\n<h1>{{ config.site_name }}</h1>\n

Rendering:

Mitigation:

"},{"location":"v2/features/pages/mkdocs-export/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/pages/mkdocs-export/#frontend-components","title":"Frontend Components","text":""},{"location":"v2/features/pages/mkdocs-export/#backend-modules","title":"Backend Modules","text":""},{"location":"v2/features/pages/mkdocs-export/#features","title":"Features","text":""},{"location":"v2/features/pages/mkdocs-export/#mkdocs-resources","title":"MkDocs Resources","text":""},{"location":"v2/features/pages/mkdocs-export/#deployment","title":"Deployment","text":""},{"location":"v2/features/pages/page-builder/","title":"Page Builder","text":"

Complete WYSIWYG landing page builder with GrapesJS editor, slug-based public routing, and MkDocs Material theme integration.

"},{"location":"v2/features/pages/page-builder/#overview","title":"Overview","text":"

The Page Builder system provides a comprehensive solution for creating custom landing pages without coding. Administrators can use a visual drag-and-drop interface or write raw HTML/CSS directly.

"},{"location":"v2/features/pages/page-builder/#key-features","title":"Key Features","text":""},{"location":"v2/features/pages/page-builder/#architecture-overview","title":"Architecture Overview","text":"
graph LR\n    A[Admin] --> B[LandingPagesPage]\n    B --> C[Create Page Modal]\n    C --> D[LandingPageEditor]\n    D --> E[GrapesJS Editor]\n    E --> F[Save API]\n    F --> G[(LandingPage Model)]\n    G --> H[Public Route]\n    H --> I[/p/:slug]\n\n    D --> J[Publish Toggle]\n    J --> K[MkDocs Export]\n    K --> L[overrides/*.html]\n    K --> M[docs/*.md stub]\n\n    style E fill:#9d4edd\n    style G fill:#3498db\n    style K fill:#2ecc71

Flow:

  1. Admin creates page via LandingPagesPage
  2. Editor loads with GrapesJS (VISUAL mode) or Monaco (CODE mode)
  3. Admin drags blocks, configures properties, saves (Ctrl+S)
  4. API stores projectData (GrapesJS JSON), htmlOutput, cssOutput
  5. On publish: API exports .html override + .md stub to MkDocs
  6. Public users access page at /p/:slug (React route renders HTML)
"},{"location":"v2/features/pages/page-builder/#database-models","title":"Database Models","text":""},{"location":"v2/features/pages/page-builder/#landingpage","title":"LandingPage","text":"

Table: landing_pages

Key Fields:

Field Type Description id String (UUID) Primary key slug String Unique URL-safe identifier (auto-generated from title) title String Page title (internal + fallback SEO) description String? Page description (internal) editorMode Enum VISUAL (GrapesJS) or CODE (raw HTML) blocks JSON GrapesJS projectData (components tree) htmlOutput String? Rendered HTML (cached output from editor) cssOutput String? Rendered CSS (cached output from editor) mkdocsPath String? Override file path (e.g., about.html) mkdocsStubPath String? Stub Markdown path (e.g., about.md) mkdocsExportMode Enum THEMED (extends main.html) or STANDALONE (full HTML) mkdocsHideNav Boolean Hide navigation sidebar in MkDocs mkdocsHideToc Boolean Hide table of contents in MkDocs mkdocsSkipExport Boolean Don't export to MkDocs (only accessible via /p/:slug) published Boolean Public visibility (false = draft) seoTitle String? Custom SEO title (overrides title) seoDescription String? Meta description for search engines seoImage String? Open Graph image URL createdAt DateTime Creation timestamp updatedAt DateTime Last modification timestamp

Indexes:

Relationships:

"},{"location":"v2/features/pages/page-builder/#pageblock","title":"PageBlock","text":"

See Block Library documentation.

"},{"location":"v2/features/pages/page-builder/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/pages/page-builder/#admin-routes","title":"Admin Routes","text":"

Prefix: /api/pages

Authentication: Requires admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN)

"},{"location":"v2/features/pages/page-builder/#list-pages","title":"List Pages","text":"
GET /api/pages?page=1&limit=20&search=campaign&published=true\n

Query Parameters:

Response:

{\n  \"pages\": [\n    {\n      \"id\": \"abc123\",\n      \"slug\": \"about-us\",\n      \"title\": \"About Our Campaign\",\n      \"description\": \"Learn more about our mission.\",\n      \"editorMode\": \"VISUAL\",\n      \"blocks\": { /* GrapesJS JSON */ },\n      \"htmlOutput\": \"<section>...</section>\",\n      \"cssOutput\": \"section { padding: 40px; }\",\n      \"mkdocsPath\": \"about.html\",\n      \"mkdocsStubPath\": \"about.md\",\n      \"mkdocsExportMode\": \"THEMED\",\n      \"mkdocsHideNav\": false,\n      \"mkdocsHideToc\": true,\n      \"mkdocsSkipExport\": false,\n      \"published\": true,\n      \"seoTitle\": \"About Us | Campaign 2026\",\n      \"seoDescription\": \"Join our movement for change.\",\n      \"seoImage\": \"https://example.com/og-image.jpg\",\n      \"createdAt\": \"2026-01-15T10:00:00Z\",\n      \"updatedAt\": \"2026-02-13T14:30:00Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 5,\n    \"totalPages\": 1\n  }\n}\n
"},{"location":"v2/features/pages/page-builder/#get-page","title":"Get Page","text":"
GET /api/pages/:id\n

Response: Single LandingPage object (same structure as list item above)

Errors:

"},{"location":"v2/features/pages/page-builder/#create-page","title":"Create Page","text":"
POST /api/pages\nContent-Type: application/json\n\n{\n  \"title\": \"New Landing Page\",\n  \"description\": \"Page description\",\n  \"editorMode\": \"VISUAL\"\n}\n

Request Body:

Response: Created LandingPage object (201 status)

Errors:

Behavior:

"},{"location":"v2/features/pages/page-builder/#update-page","title":"Update Page","text":"
PUT /api/pages/:id\nContent-Type: application/json\n\n{\n  \"blocks\": { /* GrapesJS projectData */ },\n  \"htmlOutput\": \"<section>...</section>\",\n  \"cssOutput\": \"section { padding: 40px; }\",\n  \"published\": true\n}\n

Request Body: (all fields optional)

Response: Updated LandingPage object

Errors:

Side Effects:

"},{"location":"v2/features/pages/page-builder/#delete-page","title":"Delete Page","text":"
DELETE /api/pages/:id\n

Response: 204 No Content

Errors:

Side Effects:

"},{"location":"v2/features/pages/page-builder/#sync-overrides","title":"Sync Overrides","text":"
POST /api/pages/sync\n

Purpose: Import untracked .html files from mkdocs/docs/overrides/ as CODE-mode pages. Useful for migrating hand-crafted HTML templates.

Response:

{\n  \"imported\": 2,\n  \"updated\": 1,\n  \"stubs\": 3\n}\n

Behavior:

  1. Scans mkdocs/docs/overrides/ recursively for .html files
  2. For untracked files: Creates new CODE-mode page (published=true)
  3. For tracked CODE-mode pages: Updates htmlOutput from disk (disk wins)
  4. For tracked VISUAL-mode pages: Skips (managed by GrapesJS)
  5. Backfills missing .md stubs for published pages

Use Cases:

"},{"location":"v2/features/pages/page-builder/#validate-exports","title":"Validate Exports","text":"
POST /api/pages/validate\n

Purpose: Verify MkDocs exports exist on disk, repair if missing.

Response:

{\n  \"validated\": 10,\n  \"repaired\": 2,\n  \"errors\": [\n    {\n      \"pageId\": \"xyz789\",\n      \"slug\": \"broken-page\",\n      \"error\": \"EACCES: permission denied\"\n    }\n  ]\n}\n

Behavior:

  1. Queries all published, non-skipped pages with mkdocsPath
  2. Checks if .html override and .md stub exist
  3. Re-exports if either missing
  4. Updates mkdocsStubPath if changed
  5. Returns error list for manual intervention

Use Cases:

"},{"location":"v2/features/pages/page-builder/#public-routes","title":"Public Routes","text":"

Prefix: /api/pages

Authentication: None (public access)

"},{"location":"v2/features/pages/page-builder/#view-published-page","title":"View Published Page","text":"
GET /api/pages/:slug/view\n

Example:

GET /api/pages/about-us/view\n

Response:

{\n  \"id\": \"abc123\",\n  \"slug\": \"about-us\",\n  \"title\": \"About Our Campaign\",\n  \"htmlOutput\": \"<section>...</section>\",\n  \"cssOutput\": \"section { padding: 40px; }\",\n  \"seoTitle\": \"About Us | Campaign 2026\",\n  \"seoDescription\": \"Join our movement for change.\",\n  \"seoImage\": \"https://example.com/og-image.jpg\",\n  \"createdAt\": \"2026-01-15T10:00:00Z\",\n  \"updatedAt\": \"2026-02-13T14:30:00Z\"\n}\n

Errors:

Security:

"},{"location":"v2/features/pages/page-builder/#configuration","title":"Configuration","text":""},{"location":"v2/features/pages/page-builder/#environment-variables","title":"Environment Variables","text":"
# MkDocs integration\nMKDOCS_DOCS_PATH=/mkdocs/docs\n# Override path: ${MKDOCS_DOCS_PATH}/overrides/\n# Stub path: ${MKDOCS_DOCS_PATH}/ (root of docs)\n

Docker Volume:

volumes:\n  - ./mkdocs:/mkdocs:rw\n

Note: API container needs write access to export files.

"},{"location":"v2/features/pages/page-builder/#site-settings","title":"Site Settings","text":"

Feature Flag: ENABLE_LANDING_PAGES

Location: Admin \u2192 Settings \u2192 Features \u2192 Landing Pages

Default: true

Effect: Shows/hides \"Pages\" menu item in admin sidebar

"},{"location":"v2/features/pages/page-builder/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/pages/page-builder/#creating-a-page","title":"Creating a Page","text":"
  1. Navigate: Admin sidebar \u2192 Pages
  2. Click: \"Create Page\" button
  3. Fill form:
  4. Title: \"About Us\" (slug auto-generated: about-us)
  5. Description: \"Learn about our campaign\" (optional)
  6. Editor Mode: VISUAL (default) or CODE
  7. Submit: \"Create & Edit\" button
  8. Result: Redirected to full-screen editor
"},{"location":"v2/features/pages/page-builder/#visual-editing-visual-mode","title":"Visual Editing (VISUAL Mode)","text":"
  1. Editor opens: GrapesJS interface with 3 panels:
  2. Left: Block library (drag-and-drop components)
  3. Center: Canvas (preview + inline editing)
  4. Right: Properties panel (configure selected component)
  5. Add blocks: Drag \"Hero Section\" from left panel to canvas
  6. Configure: Click hero \u2192 Edit title/subtitle/CTA in right panel
  7. Save: Press Ctrl+S (or Cmd+S on Mac) \u2192 API saves projectData, htmlOutput, cssOutput
  8. Close: Click \"X\" or \"Back to Pages\" \u2192 Returns to table
"},{"location":"v2/features/pages/page-builder/#code-editing-code-mode","title":"Code Editing (CODE Mode)","text":"
  1. Editor opens: Split-view Monaco editors:
  2. Left: HTML editor
  3. Right: CSS editor (optional)
  4. Edit HTML: Write raw HTML with Jinja2 template syntax (for MkDocs)
  5. Save: Press Ctrl+S \u2192 API saves htmlOutput, cssOutput
  6. Close: Click \"Back to Pages\"
"},{"location":"v2/features/pages/page-builder/#publishing-a-page","title":"Publishing a Page","text":"

Option 1: From Table

  1. Locate page in table
  2. Click \"Publish\" button in Actions column
  3. Status tag changes: Draft \u2192 Published
  4. Page accessible at /p/{slug}

Option 2: From Settings Modal

  1. Click gear icon (Settings) in Actions column
  2. Settings modal opens
  3. (Field not shown in modal \u2014 use table toggle)

Side Effects (on publish):

"},{"location":"v2/features/pages/page-builder/#configuring-seo","title":"Configuring SEO","text":"
  1. Click gear icon (Settings) in Actions column
  2. Fill SEO section:
  3. SEO Title: Custom title for <title> and Open Graph (defaults to title)
  4. SEO Description: Meta description for search engines
  5. SEO Image: Full URL to Open Graph image (e.g., https://cdn.example.com/og.jpg)
  6. Click \"Save\"
  7. Re-export to MkDocs if already published
"},{"location":"v2/features/pages/page-builder/#mkdocs-integration-settings","title":"MkDocs Integration Settings","text":"

Access: Page Settings modal \u2192 MkDocs Integration section

Fields:

  1. Skip MkDocs Export (checkbox)
  2. When enabled: Page NOT exported to MkDocs site
  3. Use case: Pages meant only for /p/:slug (not documentation)
  4. Default: false (export enabled)

  5. Override Path (text input)

  6. Custom filename for override (e.g., custom-about.html)
  7. Default: Auto-generated from slug ({slug}.html)
  8. Validation: Must end with .html, no path traversal

  9. Full page MkDocs (checkbox)

  10. When enabled: Exports as STANDALONE (full <!DOCTYPE html> document)
  11. When disabled: Exports as THEMED (wraps in {% extends \"main.html\" %})
  12. Default: false (THEMED)
  13. Use case: Standalone pages with no MkDocs chrome (like lander.html)

  14. Hide navigation sidebar (checkbox, only for THEMED mode)

  15. Adds hide: [navigation] to .md stub front matter
  16. Hides left sidebar on page
  17. Default: false

  18. Hide table of contents (checkbox, only for THEMED mode)

  19. Adds hide: [toc] to .md stub front matter
  20. Hides right sidebar on page
  21. Default: false

Workflow:

  1. Edit page settings
  2. Configure MkDocs options
  3. Save settings
  4. If published: API auto-exports with new settings
  5. Rebuild MkDocs: Admin \u2192 Pages \u2192 \"Build Site\" button
"},{"location":"v2/features/pages/page-builder/#syncing-overrides","title":"Syncing Overrides","text":"

Purpose: Import hand-coded .html files from disk

Workflow:

  1. Place .html files in mkdocs/docs/overrides/ (on Docker host)
  2. Admin \u2192 Pages \u2192 \"Sync Overrides\" button
  3. API scans directory, imports new files as CODE-mode pages
  4. Table refreshes, new pages appear
  5. Edit pages normally, publish as needed

Example:

# On Docker host\necho '<h1>Custom Page</h1>' > mkdocs/docs/overrides/custom.html\n\n# In admin panel\n# Click \"Sync Overrides\" \u2192 1 imported\n
"},{"location":"v2/features/pages/page-builder/#validating-exports","title":"Validating Exports","text":"

Purpose: Verify MkDocs files exist, repair if missing

Workflow:

  1. Admin \u2192 Pages \u2192 \"Validate Exports\" button
  2. API checks all published pages:
  3. .html override exists?
  4. .md stub exists?
  5. Re-exports if either missing
  6. Shows result: Validated 10 pages: 2 repaired

Use Cases:

"},{"location":"v2/features/pages/page-builder/#public-workflow","title":"Public Workflow","text":""},{"location":"v2/features/pages/page-builder/#viewing-a-published-page","title":"Viewing a Published Page","text":"
  1. User navigates: https://yoursite.com/p/about-us
  2. React router: Matches /p/:slug route \u2192 Loads LandingPage.tsx
  3. API call: GET /api/pages/about-us/view
  4. Response: Returns htmlOutput, cssOutput, SEO fields
  5. Render:
  6. Sets document.title = seoTitle || title
  7. Updates meta description, Open Graph image
  8. Injects cssOutput as <style> tag
  9. Renders htmlOutput via dangerouslySetInnerHTML
  10. Video hydration: Scans for .video-block divs, replaces placeholders with React VideoPlayer components
"},{"location":"v2/features/pages/page-builder/#seo-meta-tags","title":"SEO Meta Tags","text":"

Applied automatically on page load:

<html>\n<head>\n  <title>About Us | Campaign 2026</title>\n  <meta name=\"description\" content=\"Join our movement for change.\">\n  <meta property=\"og:image\" content=\"https://example.com/og-image.jpg\">\n</head>\n<body>\n  <style>section { padding: 40px; }</style>\n  <section>...</section>\n</body>\n</html>\n
"},{"location":"v2/features/pages/page-builder/#video-embedding","title":"Video Embedding","text":"

Editor Placeholder:

<div class=\"video-block\"\n     data-video-id=\"123\"\n     data-player-type=\"advanced\"\n     data-width=\"100%\"\n     data-autoplay=\"false\"\n     data-controls=\"true\"\n     data-show-reactions=\"true\">\n  <div class=\"video-placeholder\">\n    <!-- SVG play icon + metadata -->\n  </div>\n</div>\n

Runtime Hydration:

  1. LandingPage.tsx mounts \u2192 Scans for .video-block elements
  2. Reads data-* attributes
  3. Creates React root for each block
  4. Renders AdvancedVideoPlayer or VideoPlayer component
  5. Replaces placeholder with live player

Supported Attributes:

"},{"location":"v2/features/pages/page-builder/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/pages/page-builder/#creating-a-page-typescript","title":"Creating a Page (TypeScript)","text":"
import { api } from '@/lib/api';\n\nasync function createAboutPage() {\n  const { data } = await api.post('/pages', {\n    title: 'About Us',\n    description: 'Learn about our campaign',\n    editorMode: 'VISUAL',\n  });\n\n  console.log('Created page:', data.slug); // \"about-us\"\n  return data.id;\n}\n
"},{"location":"v2/features/pages/page-builder/#saving-editor-state-grapesjs","title":"Saving Editor State (GrapesJS)","text":"
// In LandingPageEditor component\nimport { useRef } from 'react';\nimport GrapesJSEditor, { GrapesJSEditorHandle } from '@/components/GrapesJSEditor';\n\nconst editorRef = useRef<GrapesJSEditorHandle>(null);\n\nconst handleSave = () => {\n  editorRef.current?.triggerSave(); // Calls registered save command\n};\n\nconst handleEditorSave = async (data: { projectData: any; html: string; css: string }) => {\n  await api.put(`/pages/${pageId}`, {\n    blocks: data.projectData,\n    htmlOutput: data.html,\n    cssOutput: data.css,\n  });\n  message.success('Page saved');\n};\n\nreturn (\n  <GrapesJSEditor\n    ref={editorRef}\n    initialData={page.blocks}\n    onSave={handleEditorSave}\n  />\n);\n
"},{"location":"v2/features/pages/page-builder/#fetching-published-page-public-route","title":"Fetching Published Page (Public Route)","text":"
import axios from 'axios';\n\nasync function loadLandingPage(slug: string) {\n  try {\n    const { data } = await axios.get(`/api/pages/${slug}/view`);\n\n    // Set SEO\n    document.title = data.seoTitle || data.title;\n\n    // Inject CSS\n    const style = document.createElement('style');\n    style.textContent = data.cssOutput || '';\n    document.head.appendChild(style);\n\n    // Render HTML\n    return data.htmlOutput;\n  } catch (error) {\n    if (axios.isAxiosError(error) && error.response?.status === 404) {\n      throw new Error('Page not found or unpublished');\n    }\n    throw error;\n  }\n}\n
"},{"location":"v2/features/pages/page-builder/#mkdocs-export-logic-backend","title":"MkDocs Export Logic (Backend)","text":"
// From pages.service.ts\n\nfunction wrapInMaterialOverride(html: string, css: string | null): string {\n  const styleBlock = css ? `<style>\\n${css}\\n</style>` : '';\n  return `{% extends \"main.html\" %}\n{% block content %}\n${styleBlock}\n${html}\n{% endblock %}\n`;\n}\n\nasync function exportToMkDocs(opts: ExportOptions): Promise<string> {\n  const { mkdocsPath, html, css, exportMode, title, seoTitle, seoDescription } = opts;\n\n  // Write override template\n  const filePath = path.join(MKDOCS_OVERRIDES, mkdocsPath);\n  const content = exportMode === 'STANDALONE'\n    ? wrapInStandaloneDocument(html, css, seoTitle || title, seoDescription)\n    : wrapInMaterialOverride(html, css);\n\n  await fs.writeFile(filePath, content, 'utf-8');\n\n  // Write .md stub\n  const stubPath = mkdocsPath.replace(/\\.html$/, '.md');\n  const stubContent = `---\ntemplate: ${mkdocsPath}\ntitle: \"${seoTitle || title}\"\n---\n`;\n  await fs.writeFile(path.join(MKDOCS_DOCS_ROOT, stubPath), stubContent, 'utf-8');\n\n  return stubPath;\n}\n
"},{"location":"v2/features/pages/page-builder/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/pages/page-builder/#problem-grapesjs-editor-not-loading","title":"Problem: GrapesJS Editor Not Loading","text":"

Symptoms:

Causes:

Solutions:

  1. Verify installation:

    cd admin && npm list grapesjs\n# Should show: grapesjs@0.21.x\n

  2. Check CSS import:

    // In GrapesJSEditor.tsx\nimport 'grapesjs/dist/css/grapes.min.css';\n

  3. Check browser console:

  4. Look for grapesjs variable in global scope
  5. Verify all plugins loaded successfully

  6. Clear cache:

    # In browser DevTools\n# Right-click Reload \u2192 Empty Cache and Hard Reload\n

"},{"location":"v2/features/pages/page-builder/#problem-published-page-not-rendering","title":"Problem: Published Page Not Rendering","text":"

Symptoms:

Causes:

Solutions:

  1. Verify route registration:

    // In admin/src/App.tsx\n<Route path=\"/p/:slug\" element={<LandingPage />} />\n

  2. Check slug in URL:

  3. Slug is case-sensitive: /p/About-Us \u2260 /p/about-us
  4. Use lowercase, hyphenated: /p/about-us

  5. Test API directly:

    curl http://localhost:4000/api/pages/about-us/view\n# Should return JSON, not 404\n

  6. Check published status:

    SELECT slug, published FROM landing_pages WHERE slug = 'about-us';\n-- published should be true\n

"},{"location":"v2/features/pages/page-builder/#problem-mobile-warning-shows-on-desktop","title":"Problem: Mobile Warning Shows on Desktop","text":"

Symptoms:

Causes:

Solutions:

  1. Check actual viewport width:

    // In browser console\nconsole.log(window.innerWidth);\n// Should be > 768 for desktop\n

  2. Undock DevTools:

  3. Press F12 \u2192 Click \u22ee (three dots) \u2192 Dock to right/bottom \u2192 Undock
  4. Increases available viewport width

  5. Verify breakpoint hook:

    // In PageEditorPage.tsx\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md; // md = 768px\n

  6. Test responsive mode:

  7. F12 \u2192 Toggle device toolbar (Ctrl+Shift+M)
  8. Select \"Responsive\" \u2192 Set width to 1024px
"},{"location":"v2/features/pages/page-builder/#problem-mkdocs-export-not-found","title":"Problem: MkDocs Export Not Found","text":"

Symptoms:

Causes:

Solutions:

  1. Verify publish status:

    SELECT slug, published, mkdocs_skip_export FROM landing_pages WHERE slug = 'about-us';\n-- Both should be true/false appropriately\n

  2. Check export path:

    ls -la mkdocs/docs/overrides/about.html\n# Should exist if published and not skipped\n

  3. Validate exports:

  4. Admin \u2192 Pages \u2192 \"Validate Exports\" button
  5. Check repair count

  6. Rebuild MkDocs:

    docker compose exec mkdocs mkdocs build\n# Or in admin: Pages \u2192 \"Build Site\"\n

  7. Check template path in stub:

    cat mkdocs/docs/about.md\n# Should show: template: about.html (NOT overrides/about.html)\n

"},{"location":"v2/features/pages/page-builder/#problem-slug-collision-on-create","title":"Problem: Slug Collision on Create","text":"

Symptoms:

Causes:

Solutions:

  1. Check existing pages:

    SELECT id, title, slug, published FROM landing_pages WHERE slug LIKE 'about-us%';\n

  2. Delete duplicate:

  3. If old page is unwanted: Admin \u2192 Pages \u2192 Delete
  4. New page can reuse slug

  5. Use unique title:

  6. Rename new page: \"About Us 2026\" \u2192 slug about-us-2026

  7. Manual slug override:

  8. After create: Edit page \u2192 Settings \u2192 Override Path \u2192 about-us-custom.html
"},{"location":"v2/features/pages/page-builder/#problem-video-block-not-hydrating","title":"Problem: Video Block Not Hydrating","text":"

Symptoms:

Causes:

Solutions:

  1. Check video ID in editor:
  2. Open GrapesJS editor \u2192 Select video block
  3. Properties panel \u2192 Video ID field should be numeric (e.g., 123)
  4. Not PLACEHOLDER

  5. Verify HTML output:

    <!-- Bad -->\n<div class=\"video-block\" data-video-id=\"PLACEHOLDER\">...</div>\n\n<!-- Good -->\n<div class=\"video-block\" data-video-id=\"42\">...</div>\n

  6. Check hydration script:

    // In LandingPage.tsx\nuseEffect(() => {\n  // Should scan for .video-block elements\n  const videoBlocks = contentRef.current?.querySelectorAll('.video-block');\n  console.log('Found video blocks:', videoBlocks?.length);\n}, [page]);\n

  7. Test video ID validity:

    curl http://localhost:4100/api/media/videos/42\n# Should return video metadata, not 404\n

"},{"location":"v2/features/pages/page-builder/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/pages/page-builder/#editor-initialization","title":"Editor Initialization","text":"

GrapesJS startup: ~500ms on modern desktop

Optimization strategies:

"},{"location":"v2/features/pages/page-builder/#large-pages","title":"Large Pages","text":"

Complexity threshold: 100+ components

Symptoms:

Mitigations:

"},{"location":"v2/features/pages/page-builder/#htmloutput-storage","title":"htmlOutput Storage","text":"

Database overhead: htmlOutput can be 50KB+ for complex pages

Considerations:

"},{"location":"v2/features/pages/page-builder/#public-page-rendering","title":"Public Page Rendering","text":"

React hydration: Video blocks hydrate after initial render (~100ms delay)

Performance tips:

"},{"location":"v2/features/pages/page-builder/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/features/pages/page-builder/#admin-authored-html","title":"Admin-Authored HTML","text":"

Risk: XSS via malicious HTML in editor

Mitigation:

Comment in code:

// HTML/CSS is admin-authored via GrapesJS editor (not user-submitted content).\n// Only authenticated admins can create/edit pages, so XSS risk is accepted.\nreturn <div dangerouslySetInnerHTML={{ __html: page.htmlOutput }} />;\n
"},{"location":"v2/features/pages/page-builder/#slug-validation","title":"Slug Validation","text":"

Attack vector: Path traversal via slug injection

Protection:

function generateSlug(title: string): string {\n  return title\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, '-')  // Alphanumeric + hyphens only\n    .replace(/^-+|-+$/g, '')       // Trim leading/trailing hyphens\n    .slice(0, 80);                 // Max 80 chars\n}\n

Safe slugs: about-us, campaign-2026, contact

Rejected: ../etc/passwd, <script>alert(1)</script>, ../../admin

"},{"location":"v2/features/pages/page-builder/#mkdocs-path-validation","title":"MkDocs Path Validation","text":"

Attack vector: Write arbitrary files via path traversal in mkdocsPath

Protection:

function validateMkdocsPath(mkdocsPath: string): void {\n  if (mkdocsPath.includes('\\0')) throw new Error('Null byte detected');\n\n  const normalized = path.normalize(mkdocsPath);\n  if (normalized.includes('..') || path.isAbsolute(normalized)) {\n    throw new Error('Path traversal not allowed');\n  }\n\n  if (mkdocsPath.includes('%2e') || mkdocsPath.includes('%2E')) {\n    throw new Error('Encoded path traversal not allowed');\n  }\n\n  if (!mkdocsPath.endsWith('.html')) {\n    throw new Error('Path must end with .html');\n  }\n}\n

Safe paths: about.html, pages/contact.html

Rejected: ../../../etc/passwd.html, /etc/shadow.html, %2e%2e/admin.html

"},{"location":"v2/features/pages/page-builder/#published-flag-enforcement","title":"Published Flag Enforcement","text":"

Attack vector: Access draft pages via public route

Protection:

// In pagesService.findBySlugPublic()\nif (!page || !page.published) {\n  throw new AppError(404, 'Page not found', 'PAGE_NOT_FOUND');\n}\n

Behavior:

"},{"location":"v2/features/pages/page-builder/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/pages/page-builder/#frontend-components","title":"Frontend Components","text":""},{"location":"v2/features/pages/page-builder/#backend-modules","title":"Backend Modules","text":""},{"location":"v2/features/pages/page-builder/#database","title":"Database","text":""},{"location":"v2/features/pages/page-builder/#feature-documentation","title":"Feature Documentation","text":""},{"location":"v2/features/pages/page-builder/#external-resources","title":"External Resources","text":""},{"location":"v2/features/tunnel/","title":"Tunnel Management (Pangolin)","text":"

The Tunnel Management feature provides secure public access to your self-hosted Changemaker Lite instance via Pangolin tunnel service with Newt container integration. An alternative to Cloudflare Tunnel for exposing your application to the internet.

"},{"location":"v2/features/tunnel/#overview","title":"Overview","text":"

Pangolin integration provides:

"},{"location":"v2/features/tunnel/#features","title":"Features","text":""},{"location":"v2/features/tunnel/#tunnel-setup","title":"Tunnel Setup","text":""},{"location":"v2/features/tunnel/#resource-configuration","title":"Resource Configuration","text":"

Map internal services to public subdomains:

"},{"location":"v2/features/tunnel/#admin-interface","title":"Admin Interface","text":"

Setup wizard (/app/services/pangolin):

  1. Connection - Enter Pangolin API credentials
  2. Organization - Create/select organization
  3. Site - Create/configure site
  4. Resources - Map services to subdomains
  5. Deploy - Start Newt container
  6. Verify - Test tunnel connectivity
"},{"location":"v2/features/tunnel/#status-monitoring","title":"Status Monitoring","text":""},{"location":"v2/features/tunnel/#architecture","title":"Architecture","text":""},{"location":"v2/features/tunnel/#backend-components","title":"Backend Components","text":"

Pangolin Client: - api/src/services/pangolin.client.ts - Typed HTTP client - API key authentication - Full Integration API coverage

Pangolin Module: - api/src/modules/pangolin/pangolin.routes.ts - Admin endpoints - Setup, config, status routes

Newt Container: - Docker service in docker-compose.yml - Self-hosted exit node - Routes through nginx - Automatic startup

"},{"location":"v2/features/tunnel/#frontend-components","title":"Frontend Components","text":"

Admin Page: - admin/src/pages/PangolinPage.tsx - Setup wizard - Step-by-step configuration - Status dashboard - Resource table

"},{"location":"v2/features/tunnel/#docker-integration","title":"Docker Integration","text":"

Newt container in docker-compose.yml:

newt:\n  image: bnkserve/newt:latest\n  container_name: newt\n  restart: unless-stopped\n  depends_on:\n    - nginx\n  environment:\n    NEWT_ID: ${PANGOLIN_NEWT_ID}\n    NEWT_SECRET: ${PANGOLIN_NEWT_SECRET}\n    PANGOLIN_ENDPOINT: ${PANGOLIN_ENDPOINT}\n  networks:\n    - changemaker-lite\n
"},{"location":"v2/features/tunnel/#configuration","title":"Configuration","text":""},{"location":"v2/features/tunnel/#environment-variables","title":"Environment Variables","text":"
# Pangolin API\nPANGOLIN_API_URL=https://api.bnkserve.org/v1\nPANGOLIN_API_KEY=your_api_key\n\n# Organization & Site\nPANGOLIN_ORG_ID=your_org_id\nPANGOLIN_SITE_ID=your_site_id\n\n# Newt Container\nPANGOLIN_NEWT_ID=your_newt_id\nPANGOLIN_NEWT_SECRET=your_newt_secret\nPANGOLIN_ENDPOINT=your_endpoint_url\n
"},{"location":"v2/features/tunnel/#setup-process","title":"Setup Process","text":"
  1. Create Account - Sign up at pangolin.bnkserve.org
  2. Get API Key - Generate API key in dashboard
  3. Add to .env - Set PANGOLIN_API_KEY
  4. Run Wizard - Complete setup wizard in admin
  5. Deploy Newt - Start Newt container
  6. Test Tunnel - Verify public access
"},{"location":"v2/features/tunnel/#pangolin-api-integration","title":"Pangolin API Integration","text":""},{"location":"v2/features/tunnel/#api-client-usage","title":"API Client Usage","text":"
import { pangolinClient } from '../services/pangolin.client';\n\n// Get organization\nconst org = await pangolinClient.getOrganization(orgId);\n\n// Create site\nconst site = await pangolinClient.createSite(orgId, {\n  name: 'My Campaign Site',\n  domain: 'campaign.example.com',\n});\n\n// Create resource (subdomain)\nconst resource = await pangolinClient.createResource(siteId, {\n  subdomain: 'app',\n  targetUrl: 'http://nginx:80',\n  port: 80,\n});\n\n// Get tunnel status\nconst status = await pangolinClient.getTunnelStatus(siteId);\n
"},{"location":"v2/features/tunnel/#authentication","title":"Authentication","text":"

Pangolin uses Bearer token authentication:

const headers = {\n  'Authorization': `Bearer ${apiKey}`,\n  'Content-Type': 'application/json',\n};\n
"},{"location":"v2/features/tunnel/#setup-wizard","title":"Setup Wizard","text":""},{"location":"v2/features/tunnel/#step-1-connection","title":"Step 1: Connection","text":""},{"location":"v2/features/tunnel/#step-2-organization","title":"Step 2: Organization","text":""},{"location":"v2/features/tunnel/#step-3-site","title":"Step 3: Site","text":""},{"location":"v2/features/tunnel/#step-4-resources","title":"Step 4: Resources","text":"

Example resources:

app.yoursite.com \u2192 http://nginx:80 (proxies to admin:3000)\napi.yoursite.com \u2192 http://nginx:80 (proxies to api:4000)\n
"},{"location":"v2/features/tunnel/#step-5-deploy","title":"Step 5: Deploy","text":""},{"location":"v2/features/tunnel/#step-6-verify","title":"Step 6: Verify","text":""},{"location":"v2/features/tunnel/#newt-container","title":"Newt Container","text":""},{"location":"v2/features/tunnel/#purpose","title":"Purpose","text":"

Newt is the exit node that:

"},{"location":"v2/features/tunnel/#routing","title":"Routing","text":"

All traffic flows through nginx:

Public Request\n  \u2193\nPangolin Tunnel\n  \u2193\nNewt Container\n  \u2193\nNginx (port 80)\n  \u2193\nInternal Service (admin/api/etc.)\n
"},{"location":"v2/features/tunnel/#configuration_1","title":"Configuration","text":"

Newt configured via environment variables:

"},{"location":"v2/features/tunnel/#resource-management","title":"Resource Management","text":""},{"location":"v2/features/tunnel/#resource-types","title":"Resource Types","text":""},{"location":"v2/features/tunnel/#subdomain-mapping","title":"Subdomain Mapping","text":"
interface Resource {\n  subdomain: string;      // 'app', 'api', 'docs'\n  targetUrl: string;      // 'http://nginx:80'\n  port: number;           // 80\n  protocol: string;       // 'http' or 'https'\n}\n
"},{"location":"v2/features/tunnel/#internal-routing","title":"Internal Routing","text":"

Nginx routes by Host header:

server {\n  listen 80;\n  server_name app.yoursite.com;\n\n  location / {\n    proxy_pass http://admin:3000;\n  }\n}\n\nserver {\n  listen 80;\n  server_name api.yoursite.com;\n\n  location / {\n    proxy_pass http://api:4000;\n  }\n}\n
"},{"location":"v2/features/tunnel/#status-dashboard","title":"Status Dashboard","text":""},{"location":"v2/features/tunnel/#tunnel-status","title":"Tunnel Status","text":"

Display: - Active/inactive status - Uptime duration - Last connected time - Connection errors

"},{"location":"v2/features/tunnel/#resource-health","title":"Resource Health","text":"

For each resource: - Subdomain - Target service - Health status (online/offline) - Response time - Error count

"},{"location":"v2/features/tunnel/#actions","title":"Actions","text":"

Quick actions: - Restart tunnel - Update configuration - Add/remove resources - Test connectivity - View logs

"},{"location":"v2/features/tunnel/#security","title":"Security","text":""},{"location":"v2/features/tunnel/#ssltls","title":"SSL/TLS","text":""},{"location":"v2/features/tunnel/#authentication_1","title":"Authentication","text":""},{"location":"v2/features/tunnel/#access-control","title":"Access Control","text":""},{"location":"v2/features/tunnel/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/tunnel/#connection-issues","title":"Connection Issues","text":"
  1. Verify API key
  2. Check organization/site IDs
  3. Confirm Newt credentials
  4. Test internal nginx routing
  5. Check container logs
"},{"location":"v2/features/tunnel/#resource-not-accessible","title":"Resource Not Accessible","text":"
  1. Verify resource configuration
  2. Test internal service
  3. Check nginx config
  4. Review Pangolin logs
  5. Confirm DNS propagation
"},{"location":"v2/features/tunnel/#newt-container-errors","title":"Newt Container Errors","text":"
  1. Check environment variables
  2. Verify network connectivity
  3. Review container logs
  4. Restart container
  5. Update Newt image
"},{"location":"v2/features/tunnel/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/tunnel/#admin-endpoints","title":"Admin Endpoints","text":"
GET    /api/pangolin/status            # Tunnel status\nGET    /api/pangolin/config            # Current configuration\nGET    /api/pangolin/organizations     # List organizations\nGET    /api/pangolin/sites             # List sites\nGET    /api/pangolin/resources         # List resources\nPOST   /api/pangolin/setup             # Complete setup wizard\nPOST   /api/pangolin/sync              # Sync configuration\n
"},{"location":"v2/features/tunnel/#comparison-to-cloudflare-tunnel","title":"Comparison to Cloudflare Tunnel","text":""},{"location":"v2/features/tunnel/#advantages","title":"Advantages","text":""},{"location":"v2/features/tunnel/#considerations","title":"Considerations","text":""},{"location":"v2/features/tunnel/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/","title":"Frontend Overview","text":"

The Changemaker Lite V2 frontend is a React-based admin interface built with modern web technologies, providing a comprehensive management system for campaigns, locations, media, and more.

"},{"location":"v2/frontend/#architecture","title":"Architecture","text":"

The frontend is a single-page application (SPA) built with:

"},{"location":"v2/frontend/#application-structure","title":"Application Structure","text":"
admin/\n\u251c\u2500\u2500 src/\n\u2502   \u251c\u2500\u2500 App.tsx                # Main router + route definitions\n\u2502   \u251c\u2500\u2500 components/            # Shared components\n\u2502   \u251c\u2500\u2500 pages/                 # Page components\n\u2502   \u2502   \u251c\u2500\u2500 admin/             # Admin pages (30+)\n\u2502   \u2502   \u251c\u2500\u2500 public/            # Public pages (8)\n\u2502   \u2502   \u2514\u2500\u2500 volunteer/         # Volunteer portal (4)\n\u2502   \u251c\u2500\u2500 lib/                   # API clients\n\u2502   \u251c\u2500\u2500 stores/                # Zustand state stores\n\u2502   \u251c\u2500\u2500 types/                 # TypeScript definitions\n\u2502   \u251c\u2500\u2500 hooks/                 # Custom React hooks\n\u2502   \u2514\u2500\u2500 utils/                 # Helper utilities\n\u2514\u2500\u2500 public/                    # Static assets\n
"},{"location":"v2/frontend/#key-components","title":"Key Components","text":""},{"location":"v2/frontend/#layouts","title":"Layouts","text":"

Three distinct layout components for different user contexts:

"},{"location":"v2/frontend/#components","title":"Components","text":"

Reusable UI components organized by feature:

"},{"location":"v2/frontend/#pages","title":"Pages","text":"

42 page components across three sections:

"},{"location":"v2/frontend/#state-management","title":"State Management","text":""},{"location":"v2/frontend/#zustand-stores","title":"Zustand Stores","text":"

Auth Store (stores/auth.store.ts)

Canvass Store (stores/canvass.store.ts)

"},{"location":"v2/frontend/#api-integration","title":"API Integration","text":"

Main API Client (lib/api.ts)

Media API Client (lib/media-api.ts)

Public API Client (lib/media-public-api.ts)

"},{"location":"v2/frontend/#routing","title":"Routing","text":"

Routes are organized by user role and access level:

"},{"location":"v2/frontend/#admin-routes-app","title":"Admin Routes (/app/*)","text":"

Require authentication and admin role:

<Route path=\"/app\" element={<AppLayout />}>\n  <Route path=\"dashboard\" element={<DashboardPage />} />\n  <Route path=\"users\" element={<UsersPage />} />\n  <Route path=\"influence/campaigns\" element={<CampaignsPage />} />\n  // ... 30+ admin routes\n</Route>\n
"},{"location":"v2/frontend/#public-routes","title":"Public Routes","text":"

No authentication required:

<Route path=\"/campaigns\" element={<PublicLayout />}>\n  <Route index element={<CampaignsListPage />} />\n  <Route path=\":id\" element={<CampaignPage />} />\n</Route>\n
"},{"location":"v2/frontend/#volunteer-routes-volunteer","title":"Volunteer Routes (/volunteer/*)","text":"

Require authentication, any role:

<Route path=\"/volunteer\" element={<VolunteerLayout />}>\n  <Route path=\"assignments\" element={<VolunteerShiftsPage />} />\n  <Route path=\"canvass/:cutId\" element={<VolunteerMapPage />} />\n</Route>\n
"},{"location":"v2/frontend/#theming","title":"Theming","text":""},{"location":"v2/frontend/#admin-theme","title":"Admin Theme","text":"

Light theme with primary blue colors:

colorPrimary: '#1677ff'\ncolorBgBase: '#ffffff'\n
"},{"location":"v2/frontend/#public-theme","title":"Public Theme","text":"

Dark theme with blue/teal accents:

colorBgBase: '#0d1b2a'\ncolorBgContainer: '#1b2838'\ncolorPrimary: '#3498db'\n
"},{"location":"v2/frontend/#build-development","title":"Build & Development","text":""},{"location":"v2/frontend/#development-server","title":"Development Server","text":"
cd admin && npm run dev\n# Runs on http://localhost:3000\n
"},{"location":"v2/frontend/#production-build","title":"Production Build","text":"
cd admin && npm run build\n# Output: admin/dist/\n
"},{"location":"v2/frontend/#type-checking","title":"Type Checking","text":"
cd admin && npx tsc --noEmit\n
"},{"location":"v2/frontend/#environment-variables","title":"Environment Variables","text":"

Frontend uses Vite environment variables:

VITE_API_URL=http://localhost:4000      # Main API\nVITE_MEDIA_API_URL=http://localhost:4100 # Media API\nVITE_MKDOCS_URL=http://localhost:4003   # MkDocs\n

Docker deployments override these in docker-compose.yml to use container hostnames.

"},{"location":"v2/frontend/#key-features","title":"Key Features","text":""},{"location":"v2/frontend/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/#form-handling","title":"Form Handling","text":""},{"location":"v2/frontend/#data-tables","title":"Data Tables","text":""},{"location":"v2/frontend/#map-integration","title":"Map Integration","text":""},{"location":"v2/frontend/#file-uploads","title":"File Uploads","text":""},{"location":"v2/frontend/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/#quick-links","title":"Quick Links","text":""},{"location":"v2/frontend/components/","title":"Frontend Components","text":"

Reusable UI components provide common functionality across the Changemaker Lite admin interface. Components are organized by feature area and follow React best practices.

"},{"location":"v2/frontend/components/#component-organization","title":"Component Organization","text":"
admin/src/components/\n\u251c\u2500\u2500 map/                    # Leaflet map components\n\u251c\u2500\u2500 canvass/                # GPS tracking and visit recording\n\u251c\u2500\u2500 media/                  # Video library components\n\u251c\u2500\u2500 email-templates/        # Email template editor components\n\u251c\u2500\u2500 observability/          # Monitoring components\n\u251c\u2500\u2500 AppLayout.tsx           # Admin sidebar layout\n\u251c\u2500\u2500 PublicLayout.tsx        # Public dark theme layout\n\u251c\u2500\u2500 VolunteerLayout.tsx     # Volunteer portal layout\n\u251c\u2500\u2500 MediaPublicLayout.tsx   # Public media gallery layout\n\u2514\u2500\u2500 GrapesJSEditor.tsx      # Landing page WYSIWYG editor\n
"},{"location":"v2/frontend/components/#layout-components","title":"Layout Components","text":""},{"location":"v2/frontend/components/#applayout","title":"AppLayout","text":"

Admin sidebar layout with role-based navigation:

"},{"location":"v2/frontend/components/#publiclayout","title":"PublicLayout","text":"

Dark theme layout for public pages:

"},{"location":"v2/frontend/components/#volunteerlayout","title":"VolunteerLayout","text":"

Top navigation layout for volunteer portal:

"},{"location":"v2/frontend/components/#mediapubliclayout","title":"MediaPublicLayout","text":"

Minimal layout for public media gallery:

"},{"location":"v2/frontend/components/#map-components","title":"Map Components","text":""},{"location":"v2/frontend/components/#mapcontrols","title":"MapControls","text":"

Floating control buttons for map interactions:

"},{"location":"v2/frontend/components/#addlocationmode","title":"AddLocationMode","text":"

Click-to-add location drawing mode:

"},{"location":"v2/frontend/components/#movelocationmode","title":"MoveLocationMode","text":"

Click-to-move existing locations:

"},{"location":"v2/frontend/components/#cutdrawingmode","title":"CutDrawingMode","text":"

Polygon drawing tool for geographic cuts:

"},{"location":"v2/frontend/components/#cutoverlays","title":"CutOverlays","text":"

GeoJSON polygon rendering:

"},{"location":"v2/frontend/components/#cutoverlaycontrols","title":"CutOverlayControls","text":"

Cut visibility toggle panel:

"},{"location":"v2/frontend/components/#cuteditormap","title":"CutEditorMap","text":"

Specialized map for cut editing:

"},{"location":"v2/frontend/components/#maplegend","title":"MapLegend","text":"

Floating legend overlay:

"},{"location":"v2/frontend/components/#canvass-components","title":"Canvass Components","text":""},{"location":"v2/frontend/components/#canvassheader","title":"CanvassHeader","text":"

Session header with timer and status:

"},{"location":"v2/frontend/components/#sessiontimer","title":"SessionTimer","text":"

Elapsed time display:

"},{"location":"v2/frontend/components/#canvassmarker","title":"CanvassMarker","text":"

Location marker with visit status:

"},{"location":"v2/frontend/components/#canvassmarkergroup","title":"CanvassMarkerGroup","text":"

Optimized marker clustering:

"},{"location":"v2/frontend/components/#walkingrouteline","title":"WalkingRouteLine","text":"

Polyline for walking route:

"},{"location":"v2/frontend/components/#gpstracker","title":"GPSTracker","text":"

GPS position tracking:

"},{"location":"v2/frontend/components/#canvassbottomtoolbar","title":"CanvassBottomToolbar","text":"

Bottom sheet with actions:

"},{"location":"v2/frontend/components/#visitrecordingform","title":"VisitRecordingForm","text":"

Visit outcome form:

"},{"location":"v2/frontend/components/#canvasslegend","title":"CanvassLegend","text":"

Map legend for canvass status:

"},{"location":"v2/frontend/components/#media-components","title":"Media Components","text":""},{"location":"v2/frontend/components/#videocard","title":"VideoCard","text":"

Video item display card:

"},{"location":"v2/frontend/components/#bulkactions","title":"BulkActions","text":"

Batch operation toolbar:

"},{"location":"v2/frontend/components/#uploadvideomodal","title":"UploadVideoModal","text":"

Video upload interface:

"},{"location":"v2/frontend/components/#mediagallerygrid","title":"MediaGalleryGrid","text":"

Responsive video grid:

"},{"location":"v2/frontend/components/#email-template-components","title":"Email Template Components","text":""},{"location":"v2/frontend/components/#templateeditor","title":"TemplateEditor","text":"

Email template WYSIWYG editor:

"},{"location":"v2/frontend/components/#variableinserter","title":"VariableInserter","text":"

Template variable selector:

"},{"location":"v2/frontend/components/#observability-components","title":"Observability Components","text":""},{"location":"v2/frontend/components/#metricschart","title":"MetricsChart","text":"

Prometheus metrics visualization:

"},{"location":"v2/frontend/components/#servicehealthcard","title":"ServiceHealthCard","text":"

Service status display:

"},{"location":"v2/frontend/components/#editor-components","title":"Editor Components","text":""},{"location":"v2/frontend/components/#grapesjseditor","title":"GrapesJSEditor","text":"

Landing page WYSIWYG editor:

"},{"location":"v2/frontend/components/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/layouts/","title":"Frontend Layouts","text":"

Layout components provide consistent page structure and navigation across different sections of the Changemaker Lite application. Each layout serves a specific user context with appropriate theming and navigation.

"},{"location":"v2/frontend/layouts/#layout-components","title":"Layout Components","text":""},{"location":"v2/frontend/layouts/#applayout","title":"AppLayout","text":"

Admin sidebar layout for authenticated admin users.

Location: admin/src/components/AppLayout.tsx

Features:

Route Context: /app/*

Used By: - Dashboard - User management - Campaign management - Location management - Settings pages - All admin features

Navigation Sections:

  1. Dashboard - Overview and quick actions
  2. Influence - Campaign management, responses, email queue
  3. Map - Locations, cuts, shifts, canvassing
  4. Content - Landing pages, email templates
  5. Media - Video library, public gallery, jobs
  6. Services - Integrations (Listmonk, Pangolin, MkDocs, etc.)
  7. System - Users, settings, observability

Sidebar Behavior:

"},{"location":"v2/frontend/layouts/#publiclayout","title":"PublicLayout","text":"

Dark theme layout for public-facing pages.

Location: admin/src/components/PublicLayout.tsx

Features:

Route Context: /campaigns, /map, /shifts, /p/:slug, /media

Used By: - Public campaign listing - Campaign detail pages - Response wall - Public map view - Public shift signup - Landing pages - Public media gallery

Theme Colors:

colorBgBase: '#0d1b2a'       // Dark navy background\ncolorBgContainer: '#1b2838'  // Container background\ncolorPrimary: '#3498db'      // Bright blue\ncolorLink: '#3498db'         // Link color\ncolorText: '#e0e0e0'         // Light text\n

Header Navigation:

"},{"location":"v2/frontend/layouts/#volunteerlayout","title":"VolunteerLayout","text":"

Top navigation layout for volunteer portal.

Location: admin/src/components/VolunteerLayout.tsx

Features:

Route Context: /volunteer/*

Used By: - Volunteer dashboard - Shift assignments - Canvass map (linked from assignments) - Activity history - Route history

Navigation Items:

  1. Dashboard - Overview and stats
  2. Assignments - Assigned shifts
  3. Activity - Visit history
  4. Routes - Walking route history

Mobile Behavior:

"},{"location":"v2/frontend/layouts/#mediapubliclayout","title":"MediaPublicLayout","text":"

Minimal layout for public media gallery.

Location: admin/src/components/MediaPublicLayout.tsx

Features:

Route Context: /media, /media/:id

Used By: - Public media gallery page - Video viewer page

"},{"location":"v2/frontend/layouts/#layout-selection-pattern","title":"Layout Selection Pattern","text":"

Layouts are selected based on route context:

// Admin routes use AppLayout\n<Route path=\"/app\" element={<AppLayout />}>\n  <Route path=\"dashboard\" element={<DashboardPage />} />\n  <Route path=\"users\" element={<UsersPage />} />\n  // ... more admin routes\n</Route>\n\n// Public routes use PublicLayout\n<Route element={<PublicLayout />}>\n  <Route path=\"/campaigns\" element={<CampaignsListPage />} />\n  <Route path=\"/campaigns/:id\" element={<CampaignPage />} />\n  <Route path=\"/map\" element={<MapPage />} />\n</Route>\n\n// Volunteer routes use VolunteerLayout\n<Route path=\"/volunteer\" element={<VolunteerLayout />}>\n  <Route path=\"dashboard\" element={<VolunteerDashboardPage />} />\n  <Route path=\"assignments\" element={<VolunteerShiftsPage />} />\n</Route>\n\n// Some pages are full-screen (no layout)\n<Route path=\"/volunteer/canvass/:cutId\" element={<VolunteerMapPage />} />\n<Route path=\"/app/pages/:id/edit\" element={<PageEditorPage />} />\n
"},{"location":"v2/frontend/layouts/#full-screen-pages","title":"Full-Screen Pages","text":"

Some pages render without any layout wrapper:

These pages handle their own navigation and controls.

"},{"location":"v2/frontend/layouts/#layout-customization","title":"Layout Customization","text":""},{"location":"v2/frontend/layouts/#theme-overrides","title":"Theme Overrides","text":"

Layouts use Ant Design ConfigProvider for theming:

<ConfigProvider\n  theme={{\n    token: {\n      colorPrimary: '#3498db',\n      colorBgBase: '#0d1b2a',\n      // ... more tokens\n    },\n  }}\n>\n  {children}\n</ConfigProvider>\n
"},{"location":"v2/frontend/layouts/#role-based-navigation","title":"Role-Based Navigation","text":"

AppLayout filters menu items based on user role:

const menuItems = [\n  { key: 'dashboard', label: 'Dashboard', icon: <DashboardOutlined /> },\n\n  // Influence section - only for SUPER_ADMIN, INFLUENCE_ADMIN\n  user.role === 'SUPER_ADMIN' || user.role === 'INFLUENCE_ADMIN' ? {\n    key: 'influence',\n    label: 'Influence',\n    children: [...]\n  } : null,\n\n  // Map section - only for SUPER_ADMIN, MAP_ADMIN\n  user.role === 'SUPER_ADMIN' || user.role === 'MAP_ADMIN' ? {\n    key: 'map',\n    label: 'Map',\n    children: [...]\n  } : null,\n].filter(Boolean);\n
"},{"location":"v2/frontend/layouts/#responsive-breakpoints","title":"Responsive Breakpoints","text":"

Layouts use Ant Design grid breakpoints:

Access via Grid.useBreakpoint():

const screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n
"},{"location":"v2/frontend/layouts/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/","title":"Frontend Pages","text":"

Page components provide the main user interface screens for Changemaker Lite. Pages are organized into three categories based on user access and context.

"},{"location":"v2/frontend/pages/#page-categories","title":"Page Categories","text":""},{"location":"v2/frontend/pages/#admin-pages-30-pages","title":"Admin Pages (30 pages)","text":"

Authenticated admin interface for campaign management, location management, settings, and system administration. Requires admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN).

Route Prefix: /app/*

Layout: AppLayout (sidebar navigation)

Key Pages: - Dashboard - User management - Campaign management - Location and mapping - Settings and configuration - Media library - Service integrations

"},{"location":"v2/frontend/pages/#public-pages-8-pages","title":"Public Pages (8 pages)","text":"

Public-facing pages accessible without authentication. Used by campaign supporters and volunteers to view campaigns, sign up for shifts, and interact with content.

Route Prefix: Various (/campaigns, /map, /shifts, /p/:slug, /media)

Layout: PublicLayout (dark theme)

Key Pages: - Campaign listing and details - Response wall - Public map view - Shift signup - Landing pages - Media gallery

"},{"location":"v2/frontend/pages/#volunteer-pages-4-pages","title":"Volunteer Pages (4 pages)","text":"

Volunteer portal for canvassing activities. Requires authentication (any role) and provides tools for door-to-door canvassing, GPS tracking, and activity tracking.

Route Prefix: /volunteer/*

Layout: VolunteerLayout (top navigation)

Key Pages: - Volunteer dashboard - Shift assignments - Full-screen canvass map - Activity history - Route history

"},{"location":"v2/frontend/pages/#page-overview-by-feature","title":"Page Overview by Feature","text":""},{"location":"v2/frontend/pages/#authentication","title":"Authentication","text":""},{"location":"v2/frontend/pages/#dashboard-analytics","title":"Dashboard & Analytics","text":""},{"location":"v2/frontend/pages/#campaign-management","title":"Campaign Management","text":""},{"location":"v2/frontend/pages/#location-mapping","title":"Location & Mapping","text":""},{"location":"v2/frontend/pages/#canvassing","title":"Canvassing","text":""},{"location":"v2/frontend/pages/#content-management","title":"Content Management","text":""},{"location":"v2/frontend/pages/#media-management","title":"Media Management","text":""},{"location":"v2/frontend/pages/#system-settings","title":"System & Settings","text":""},{"location":"v2/frontend/pages/#service-integrations","title":"Service Integrations","text":""},{"location":"v2/frontend/pages/#page-count-summary","title":"Page Count Summary","text":"Category Count Description Admin 30 Admin interface pages Public 8 Public-facing pages Volunteer 4 Volunteer portal pages Total 42 All page components"},{"location":"v2/frontend/pages/#common-page-patterns","title":"Common Page Patterns","text":""},{"location":"v2/frontend/pages/#data-tables","title":"Data Tables","text":"

Most CRUD pages use Ant Design Table with:

"},{"location":"v2/frontend/pages/#forms","title":"Forms","text":"

Form pages use Ant Design Form with:

"},{"location":"v2/frontend/pages/#maps","title":"Maps","text":"

Map pages use React Leaflet with:

"},{"location":"v2/frontend/pages/#mobile-responsiveness","title":"Mobile Responsiveness","text":"

Pages use responsive design patterns:

"},{"location":"v2/frontend/pages/#route-protection","title":"Route Protection","text":"

Pages are protected based on authentication and role:

// Public routes - no auth required\n<Route path=\"/campaigns\" element={<CampaignsListPage />} />\n\n// Authenticated routes - any role\n<Route path=\"/volunteer/assignments\" element={<VolunteerShiftsPage />} />\n\n// Admin routes - specific roles\n<Route path=\"/app/campaigns\" element={<CampaignsPage />} />\n// Middleware: requireRole(SUPER_ADMIN, INFLUENCE_ADMIN)\n
"},{"location":"v2/frontend/pages/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/","title":"Admin Pages","text":"

Admin pages provide the main administrative interface for managing campaigns, locations, content, media, and system settings. All admin pages require authentication and appropriate role permissions.

"},{"location":"v2/frontend/pages/admin/#route-context","title":"Route Context","text":""},{"location":"v2/frontend/pages/admin/#dashboard-overview","title":"Dashboard & Overview","text":""},{"location":"v2/frontend/pages/admin/#dashboard-page","title":"Dashboard Page","text":"

Route: /app/dashboard

Main admin landing page with:

Role: Any admin role

"},{"location":"v2/frontend/pages/admin/#user-management","title":"User Management","text":""},{"location":"v2/frontend/pages/admin/#users-page","title":"Users Page","text":"

Route: /app/users

User CRUD interface with:

Role: SUPER_ADMIN only

"},{"location":"v2/frontend/pages/admin/#settings-page","title":"Settings Page","text":"

Route: /app/settings

Global site settings:

Role: SUPER_ADMIN only

"},{"location":"v2/frontend/pages/admin/#influence-module","title":"Influence Module","text":""},{"location":"v2/frontend/pages/admin/#campaigns-page","title":"Campaigns Page","text":"

Route: /app/influence/campaigns

Campaign management:

Role: SUPER_ADMIN, INFLUENCE_ADMIN

"},{"location":"v2/frontend/pages/admin/#responses-page","title":"Responses Page","text":"

Route: /app/influence/responses

Response moderation:

Role: SUPER_ADMIN, INFLUENCE_ADMIN

"},{"location":"v2/frontend/pages/admin/#representatives-page","title":"Representatives Page","text":"

Route: /app/influence/representatives

Representative cache:

Role: SUPER_ADMIN, INFLUENCE_ADMIN

"},{"location":"v2/frontend/pages/admin/#email-queue-page","title":"Email Queue Page","text":"

Route: /app/influence/email-queue

BullMQ queue monitoring:

Role: SUPER_ADMIN, INFLUENCE_ADMIN

"},{"location":"v2/frontend/pages/admin/#map-module","title":"Map Module","text":""},{"location":"v2/frontend/pages/admin/#locations-page","title":"Locations Page","text":"

Route: /app/map/locations

Location database management:

Role: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/frontend/pages/admin/#cuts-page","title":"Cuts Page","text":"

Route: /app/map/cuts

Geographic cut management:

Role: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/frontend/pages/admin/#shifts-page","title":"Shifts Page","text":"

Route: /app/map/shifts

Volunteer shift management:

Role: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/frontend/pages/admin/#map-settings-page","title":"Map Settings Page","text":"

Route: /app/map/settings

Map configuration:

Role: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/frontend/pages/admin/#data-quality-dashboard-page","title":"Data Quality Dashboard Page","text":"

Route: /app/map/data-quality

Geocoding quality metrics:

Role: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/frontend/pages/admin/#canvassing","title":"Canvassing","text":""},{"location":"v2/frontend/pages/admin/#canvass-dashboard-page","title":"Canvass Dashboard Page","text":"

Route: /app/canvass/dashboard

Canvass monitoring:

Role: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/frontend/pages/admin/#walk-sheet-page","title":"Walk Sheet Page","text":"

Route: /app/canvass/walk-sheet

Printable walk sheet:

Role: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/frontend/pages/admin/#cut-export-page","title":"Cut Export Page","text":"

Route: /app/canvass/cut-export

Printable location report:

Role: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/frontend/pages/admin/#content-management","title":"Content Management","text":""},{"location":"v2/frontend/pages/admin/#landing-pages-page","title":"Landing Pages Page","text":"

Route: /app/pages

Landing page CRUD:

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#page-editor-page","title":"Page Editor Page","text":"

Route: /app/pages/:id/edit

GrapesJS WYSIWYG editor:

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#email-templates-page","title":"Email Templates Page","text":"

Route: /app/email-templates

Email template CRUD:

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#email-template-editor-page","title":"Email Template Editor Page","text":"

Route: /app/email-templates/:id/edit

Email template editor:

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#media-management","title":"Media Management","text":""},{"location":"v2/frontend/pages/admin/#library-page","title":"Library Page","text":"

Route: /app/media/library

Video library management:

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#shared-media-page","title":"Shared Media Page","text":"

Route: /app/media/shared

Public gallery administration:

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#media-jobs-page","title":"Media Jobs Page","text":"

Route: /app/media/jobs

Job queue monitoring:

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#service-integrations","title":"Service Integrations","text":""},{"location":"v2/frontend/pages/admin/#listmonk-page","title":"Listmonk Page","text":"

Route: /app/services/listmonk

Newsletter sync management:

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#pangolin-page","title":"Pangolin Page","text":"

Route: /app/services/pangolin

Tunnel setup wizard:

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#docs-page","title":"Docs Page","text":"

Route: /app/services/docs

MkDocs management:

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#mkdocs-settings-page","title":"MkDocs Settings Page","text":"

Route: /app/services/mkdocs-settings

Documentation configuration:

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#mini-qr-page","title":"Mini QR Page","text":"

Route: /app/services/qr

QR code service iframe:

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#mailhog-page","title":"MailHog Page","text":"

Route: /app/services/mailhog

Email capture UI:

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#code-editor-page","title":"Code Editor Page","text":"

Route: /app/services/code

Code Server management:

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#n8n-page","title":"N8n Page","text":"

Route: /app/services/n8n

Workflow automation:

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#gitea-page","title":"Gitea Page","text":"

Route: /app/services/gitea

Git repository hosting:

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#nocodb-page","title":"NocoDB Page","text":"

Route: /app/services/nocodb

Data browser management:

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#monitoring","title":"Monitoring","text":""},{"location":"v2/frontend/pages/admin/#observability-page","title":"Observability Page","text":"

Route: /app/observability

Monitoring dashboard:

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#admin-page-count","title":"Admin Page Count","text":"

Total: 30 admin pages

"},{"location":"v2/frontend/pages/admin/#common-features","title":"Common Features","text":"

Most admin pages include:

"},{"location":"v2/frontend/pages/admin/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/","title":"CampaignsPage","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#overview","title":"Overview","text":"

The CampaignsPage provides complete CRUD management for advocacy email campaigns in the Influence module. It displays campaigns in a paginated table with search, status filtering, and quick actions for viewing, editing, deleting, and accessing email statistics. Features include campaign highlighting, government level targeting, and comprehensive feature flags for customizing campaign behavior.

Route: /app/influence/campaigns Component: admin/src/pages/CampaignsPage.tsx (507 lines) Auth Required: Yes (SUPER_ADMIN, INFLUENCE_ADMIN roles) Layout: AppLayout

"},{"location":"v2/frontend/pages/admin/campaigns-page/#screenshot","title":"Screenshot","text":"

[Screenshot: Campaigns page with search bar at top left, status filter dropdown at top right, and \"Create Campaign\" button in page header. Main table shows columns: Title (with highlighted star icon for featured campaigns + public slug), Status (colored tags), Gov. Levels (multiple colored tags), Emails (count), Responses (count), Created (date), and Actions (5 icon buttons: view public page, copy link, view emails, edit, delete). Below table is pagination showing \"X campaigns\" total.]

"},{"location":"v2/frontend/pages/admin/campaigns-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#viewing-campaigns-list","title":"Viewing Campaigns List","text":"
  1. Navigate to /app/influence/campaigns
  2. Page loads first 20 campaigns (pagination)
  3. View campaign stats: Emails count, Responses count
  4. See campaign status with colored tags
  5. Identify featured campaigns by star icon (highlightCampaign)
  6. Note public URL slug below campaign title
"},{"location":"v2/frontend/pages/admin/campaigns-page/#creating-a-new-campaign","title":"Creating a New Campaign","text":"
  1. Click \"Create Campaign\" button in page header
  2. Modal opens (640px width) with vertical form
  3. Fill required fields:
  4. Title (auto-generates slug from title)
  5. Email Subject
  6. Email Body (template shown to users)
  7. Fill optional fields:
  8. Description (internal note, not shown to public)
  9. Call to Action (additional instructions for users)
  10. Government Levels (multi-select: Federal, Provincial, Municipal, School Board)
  11. Cover Photo URL (hero image on public campaign page)
  12. Status (default: DRAFT)
  13. Configure feature flags (9 switches in 2-column grid):
  14. Default ON: allowSmtpEmail, allowMailtoLink, collectUserInfo, showEmailCount, showCallCount
  15. Default OFF: allowEmailEditing, allowCustomRecipients, showResponseWall, highlightCampaign
  16. Click \"Create\" button
  17. Success message: \"Campaign created\"
  18. Modal closes, table refreshes to page 1
  19. New campaign appears at top (most recent first)
"},{"location":"v2/frontend/pages/admin/campaigns-page/#editing-an-existing-campaign","title":"Editing an Existing Campaign","text":"
  1. Locate campaign in table
  2. Click Edit icon button (EditOutlined) in Actions column
  3. Edit modal opens (640px width) with pre-filled values
  4. Modify any fields (same form as create)
  5. Click \"Save\" button
  6. Success message: \"Campaign updated\"
  7. Modal closes, table refreshes with updated data
  8. If title changed, slug auto-updates
"},{"location":"v2/frontend/pages/admin/campaigns-page/#viewing-campaign-emails","title":"Viewing Campaign Emails","text":"
  1. Locate campaign in table
  2. Click Mail icon button (MailOutlined) in Actions column
  3. CampaignEmailsDrawer opens on right side (see CampaignEmailsDrawer)
  4. View email statistics:
  5. Total emails sent
  6. Delivered, failed, pending counts
  7. Email list with recipient, status, timestamp
  8. Click \"X\" to close drawer
"},{"location":"v2/frontend/pages/admin/campaigns-page/#publishing-a-campaign","title":"Publishing a Campaign","text":"
  1. Open campaign in edit modal
  2. Change Status dropdown from DRAFT to ACTIVE
  3. Click \"Save\"
  4. Campaign now visible on public /campaigns page
  5. View icon button (EyeOutlined) now enabled
  6. Click View to open public campaign page in new tab
"},{"location":"v2/frontend/pages/admin/campaigns-page/#copying-public-campaign-link","title":"Copying Public Campaign Link","text":"
  1. Locate ACTIVE campaign in table
  2. Click Link icon button (LinkOutlined) in Actions column
  3. URL copied to clipboard: http://app.cmlite.org/campaign/{slug}
  4. Success message: \"Campaign link copied\"
  5. Share link with supporters
"},{"location":"v2/frontend/pages/admin/campaigns-page/#searching-and-filtering","title":"Searching and Filtering","text":"
  1. Use search bar at top left:
  2. Type title or description keywords
  3. 300ms debounce (waits for typing to stop)
  4. Search resets pagination to page 1
  5. Use status filter dropdown at top right:
  6. Select DRAFT, ACTIVE, PAUSED, or ARCHIVED
  7. Filter resets pagination to page 1
  8. Clear filter to show all campaigns
  9. Filters persist during pagination
"},{"location":"v2/frontend/pages/admin/campaigns-page/#deleting-a-campaign","title":"Deleting a Campaign","text":"
  1. Locate campaign in table
  2. Click Delete icon button (DeleteOutlined) in Actions column
  3. Popconfirm appears: \"Delete this campaign?\"
  4. Description: \"All associated emails and responses will also be deleted.\"
  5. Click \"OK\" to confirm
  6. Success message: \"Campaign deleted\"
  7. Table refreshes
  8. Associated CampaignEmail and Response records also deleted (cascade)
"},{"location":"v2/frontend/pages/admin/campaigns-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#ant-design-components-used","title":"Ant Design Components Used","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#table-columns","title":"Table Columns","text":"
const columns: ColumnsType<Campaign> = [\n  {\n    title: 'Title',\n    dataIndex: 'title',\n    key: 'title',\n    render: (title, record) => (\n      <div>\n        <Space>\n          <span style={{ fontWeight: 500 }}>{title}</span>\n          {record.highlightCampaign && <StarFilled style={{ color: '#faad14' }} />}\n        </Space>\n        <div style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)' }}>/campaign/{record.slug}</div>\n      </div>\n    ),\n  },\n  {\n    title: 'Status',\n    dataIndex: 'status',\n    render: (status) => <Tag color={statusColors[status]}>{status}</Tag>,\n  },\n  {\n    title: 'Gov. Levels',\n    dataIndex: 'targetGovernmentLevels',\n    render: (levels: GovernmentLevel[]) =>\n      levels.map((l) => <Tag key={l} color={govLevelColors[l]}>{l.replace('_', ' ')}</Tag>),\n    responsive: ['md'],\n  },\n  {\n    title: 'Emails',\n    render: (_, record) => record._count.emails,\n    responsive: ['md'],\n  },\n  {\n    title: 'Responses',\n    render: (_, record) => record._count.responses,\n    responsive: ['lg'],\n  },\n  {\n    title: 'Created',\n    dataIndex: 'createdAt',\n    render: (date) => dayjs(date).format('YYYY-MM-DD'),\n    responsive: ['md'],\n  },\n  {\n    title: 'Actions',\n    render: (_, record) => (\n      <Space>\n        {/* View public page (ACTIVE only) */}\n        {/* Copy link */}\n        {/* View emails drawer */}\n        {/* Edit modal */}\n        {/* Delete popconfirm */}\n      </Space>\n    ),\n  },\n];\n

Key patterns: - _count aggregation fields from Prisma (emails, responses) - Responsive column visibility with responsive: ['md'] - Conditional rendering: View button only for ACTIVE campaigns

"},{"location":"v2/frontend/pages/admin/campaigns-page/#status-colors","title":"Status Colors","text":"
const statusColors: Record<CampaignStatus, string> = {\n  DRAFT: 'default',    // Gray\n  ACTIVE: 'green',     // Green\n  PAUSED: 'orange',    // Orange\n  ARCHIVED: 'gray',    // Gray\n};\n
"},{"location":"v2/frontend/pages/admin/campaigns-page/#government-level-colors","title":"Government Level Colors","text":"
const govLevelColors: Record<GovernmentLevel, string> = {\n  FEDERAL: 'blue',\n  PROVINCIAL: 'purple',\n  MUNICIPAL: 'cyan',\n  SCHOOL_BOARD: 'magenta',\n};\n
"},{"location":"v2/frontend/pages/admin/campaigns-page/#feature-flags-form-section","title":"Feature Flags Form Section","text":"
<Divider orientation=\"left\" plain>Feature Flags</Divider>\n<Row gutter={[16, 8]}>\n  <Col xs={24} sm={12}>\n    <Form.Item name=\"allowSmtpEmail\" label=\"Allow SMTP Email\" valuePropName=\"checked\" initialValue={true}>\n      <Switch />\n    </Form.Item>\n  </Col>\n  <Col xs={24} sm={12}>\n    <Form.Item name=\"allowMailtoLink\" label=\"Allow Mailto Link\" valuePropName=\"checked\" initialValue={true}>\n      <Switch />\n    </Form.Item>\n  </Col>\n  {/* 7 more switches */}\n</Row>\n

Pattern: 9 switches in 2-column responsive grid (xs: 1 column, sm+: 2 columns)

"},{"location":"v2/frontend/pages/admin/campaigns-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#zustand-stores-used","title":"Zustand Stores Used","text":"

None \u2014 Campaigns are fetched from API on each page load. No global state required.

"},{"location":"v2/frontend/pages/admin/campaigns-page/#local-state","title":"Local State","text":"
const [campaigns, setCampaigns] = useState<Campaign[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });\nconst [loading, setLoading] = useState(false);\nconst [search, setSearch] = useState('');\nconst [debouncedSearch, setDebouncedSearch] = useState('');\nconst searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\nconst [statusFilter, setStatusFilter] = useState<CampaignStatus | undefined>();\nconst [createModalOpen, setCreateModalOpen] = useState(false);\nconst [editModalOpen, setEditModalOpen] = useState(false);\nconst [editingCampaign, setEditingCampaign] = useState<Campaign | null>(null);\nconst [emailsDrawerOpen, setEmailsDrawerOpen] = useState(false);\nconst [emailsCampaign, setEmailsCampaign] = useState<Campaign | null>(null);\nconst [createForm] = Form.useForm();\nconst [editForm] = Form.useForm();\n

Debounced search pattern:

const handleSearchChange = (value: string) => {\n  setSearch(value);               // Update input immediately\n  clearTimeout(searchTimerRef.current);\n  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);  // Debounce API call\n};\n\nuseEffect(() => {\n  fetchCampaigns({ page: 1 });\n}, [debouncedSearch, statusFilter]);  // Re-fetch when debounced search or filter changes\n\nuseEffect(() => {\n  return () => clearTimeout(searchTimerRef.current);  // Cleanup on unmount\n}, []);\n

Why 300ms debounce? Prevents API spam while typing. Only fetches when user pauses.

"},{"location":"v2/frontend/pages/admin/campaigns-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose GET /api/campaigns List campaigns (paginated, filtered) POST /api/campaigns Create campaign PUT /api/campaigns/:id Update campaign DELETE /api/campaigns/:id Delete campaign (cascade emails + responses)"},{"location":"v2/frontend/pages/admin/campaigns-page/#list-campaigns","title":"List Campaigns","text":"

Request:

const { data } = await api.get<CampaignsListResponse>('/campaigns', {\n  params: {\n    page: 1,\n    limit: 20,\n    search: 'climate',       // Optional: search title/description\n    status: 'ACTIVE',        // Optional: filter by status\n  },\n});\n

Response:

{\n  \"campaigns\": [\n    {\n      \"id\": \"cm-123\",\n      \"title\": \"Contact Your MP About Climate Action\",\n      \"slug\": \"contact-your-mp-about-climate-action\",\n      \"description\": \"Urge federal representatives to support renewable energy legislation\",\n      \"emailSubject\": \"Support Climate Action Now\",\n      \"emailBody\": \"Dear [Representative Name],\\n\\nI am writing to urge you to support...\",\n      \"callToAction\": \"Remember to follow up with a phone call next week!\",\n      \"status\": \"ACTIVE\",\n      \"targetGovernmentLevels\": [\"FEDERAL\"],\n      \"allowSmtpEmail\": true,\n      \"allowMailtoLink\": true,\n      \"collectUserInfo\": true,\n      \"showEmailCount\": true,\n      \"showCallCount\": false,\n      \"allowEmailEditing\": false,\n      \"allowCustomRecipients\": false,\n      \"showResponseWall\": true,\n      \"highlightCampaign\": true,\n      \"coverPhoto\": \"https://example.com/climate.jpg\",\n      \"createdAt\": \"2026-01-15T10:30:00.000Z\",\n      \"updatedAt\": \"2026-01-20T14:45:00.000Z\",\n      \"_count\": {\n        \"emails\": 847,\n        \"responses\": 23\n      }\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 12,\n    \"totalPages\": 1\n  }\n}\n

Key fields: - slug \u2014 URL-friendly identifier (auto-generated from title) - targetGovernmentLevels \u2014 Array of government levels (empty array = all) - _count \u2014 Prisma aggregation with email and response counts - Feature flags \u2014 9 boolean fields controlling campaign behavior

"},{"location":"v2/frontend/pages/admin/campaigns-page/#create-campaign","title":"Create Campaign","text":"

Request:

const payload: CreateCampaignPayload = {\n  title: \"Stop Deforestation in Northern Ontario\",\n  description: \"Internal campaign note\",\n  emailSubject: \"Protect Our Forests\",\n  emailBody: \"Dear [Representative Name],\\n\\nI urge you to...\",\n  callToAction: \"Share this campaign on social media!\",\n  status: \"DRAFT\",\n  targetGovernmentLevels: [\"PROVINCIAL\"],\n  allowSmtpEmail: true,\n  allowMailtoLink: true,\n  collectUserInfo: true,\n  showEmailCount: true,\n  showCallCount: false,\n  allowEmailEditing: false,\n  allowCustomRecipients: false,\n  showResponseWall: false,\n  highlightCampaign: false,\n  coverPhoto: \"https://example.com/forest.jpg\",\n};\n\nawait api.post('/campaigns', payload);\n

Response:

{\n  \"id\": \"cm-456\",\n  \"title\": \"Stop Deforestation in Northern Ontario\",\n  \"slug\": \"stop-deforestation-in-northern-ontario\",\n  \"status\": \"DRAFT\",\n  \"createdAt\": \"2026-02-11T09:00:00.000Z\",\n  // ... all other fields\n}\n

Slug generation: Backend auto-generates slug from title (lowercase, hyphens replace spaces/punctuation)

"},{"location":"v2/frontend/pages/admin/campaigns-page/#update-campaign","title":"Update Campaign","text":"

Request:

const payload: UpdateCampaignPayload = {\n  status: \"ACTIVE\",              // Publish campaign\n  highlightCampaign: true,       // Feature on campaigns list\n  showResponseWall: true,        // Enable response submissions\n};\n\nawait api.put(`/campaigns/${campaignId}`, payload);\n

Response:

{\n  \"id\": \"cm-456\",\n  \"status\": \"ACTIVE\",\n  \"highlightCampaign\": true,\n  \"showResponseWall\": true,\n  \"updatedAt\": \"2026-02-11T10:15:00.000Z\",\n  // ... all other fields\n}\n

Partial updates: Only send changed fields, backend merges with existing record.

"},{"location":"v2/frontend/pages/admin/campaigns-page/#delete-campaign","title":"Delete Campaign","text":"

Request:

await api.delete(`/campaigns/${campaignId}`);\n

Response: 204 No Content

Cascade behavior: Prisma cascade deletes: - All CampaignEmail records (sent emails) - All Response records (public responses) - All PostalCodeCache entries referencing this campaign

Warning: Shown in Popconfirm: \"All associated emails and responses will also be deleted.\"

"},{"location":"v2/frontend/pages/admin/campaigns-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#debounced-search-implementation","title":"Debounced Search Implementation","text":"
const [search, setSearch] = useState('');\nconst [debouncedSearch, setDebouncedSearch] = useState('');\nconst searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\n\nconst handleSearchChange = (value: string) => {\n  setSearch(value);                             // Update input immediately (controlled component)\n  clearTimeout(searchTimerRef.current);         // Cancel previous timer\n  searchTimerRef.current = setTimeout(() => {\n    setDebouncedSearch(value);                  // Update debounced value after 300ms\n  }, 300);\n};\n\nuseEffect(() => {\n  return () => clearTimeout(searchTimerRef.current);  // Cleanup timer on unmount\n}, []);\n\nuseEffect(() => {\n  fetchCampaigns({ page: 1 });                  // Re-fetch when debounced search changes\n}, [debouncedSearch, statusFilter]);            // Also re-fetch when filter changes\n

Benefits: - User sees immediate feedback in input (controlled) - API only called once per 300ms (prevents spam) - Timer cleared on unmount (no memory leaks)

"},{"location":"v2/frontend/pages/admin/campaigns-page/#usecallback-optimization","title":"useCallback Optimization","text":"
const fetchCampaigns = useCallback(async (params?: CampaignsListParams) => {\n  setLoading(true);\n  try {\n    const { data } = await api.get<CampaignsListResponse>('/campaigns', {\n      params: {\n        page: params?.page ?? pagination.page,\n        limit: params?.limit ?? pagination.limit,\n        search: params?.search ?? (debouncedSearch || undefined),\n        status: params?.status ?? statusFilter,\n      },\n    });\n    setCampaigns(data.campaigns);\n    setPagination(data.pagination);\n  } catch {\n    message.error('Failed to load campaigns');\n  } finally {\n    setLoading(false);\n  }\n}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]);\n

Why useCallback? Memoizes function, prevents re-creating on every render. Dependencies array ensures function updates when pagination, search, or filter changes.

"},{"location":"v2/frontend/pages/admin/campaigns-page/#color-coded-government-level-tags","title":"Color-Coded Government Level Tags","text":"
const govLevelColors: Record<GovernmentLevel, string> = {\n  FEDERAL: 'blue',\n  PROVINCIAL: 'purple',\n  MUNICIPAL: 'cyan',\n  SCHOOL_BOARD: 'magenta',\n};\n\n// In table column render:\n{\n  title: 'Gov. Levels',\n  dataIndex: 'targetGovernmentLevels',\n  render: (levels: GovernmentLevel[]) =>\n    levels.length > 0\n      ? levels.map((l) => (\n          <Tag key={l} color={govLevelColors[l]} style={{ fontSize: 11 }}>\n            {l.replace('_', ' ')}  // \"SCHOOL_BOARD\" \u2192 \"SCHOOL BOARD\"\n          </Tag>\n        ))\n      : '--',\n  responsive: ['md'],\n}\n

Pattern: Map each government level to a colored tag, replace underscores with spaces for readability.

"},{"location":"v2/frontend/pages/admin/campaigns-page/#reusable-form-fields-component","title":"Reusable Form Fields Component","text":"
const campaignFormFields = (\n  <>\n    <Form.Item name=\"title\" label=\"Title\" rules={[{ required: true }]}>\n      <Input />\n    </Form.Item>\n    <Form.Item name=\"description\" label=\"Description\">\n      <TextArea rows={2} />\n    </Form.Item>\n    {/* ... all other fields */}\n    <Divider orientation=\"left\" plain>Feature Flags</Divider>\n    <Row gutter={[16, 8]}>\n      {/* 9 switches in 2-column grid */}\n    </Row>\n  </>\n);\n\n// Used in both create and edit modals:\n<Form form={createForm} onFinish={handleCreate} layout=\"vertical\">\n  {campaignFormFields}\n</Form>\n\n<Form form={editForm} onFinish={handleEdit} layout=\"vertical\">\n  {campaignFormFields}\n</Form>\n

Benefits: - DRY principle (Don't Repeat Yourself) - Single source of truth for form structure - Easy to add/modify fields in one place

"},{"location":"v2/frontend/pages/admin/campaigns-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#debounced-search","title":"Debounced Search","text":"

300ms debounce prevents API spam: - User typing \"climate action\" fires 1 API call (not 14) - Reduces server load, improves responsiveness - Uses clearTimeout to cancel pending calls

"},{"location":"v2/frontend/pages/admin/campaigns-page/#responsive-column-hiding","title":"Responsive Column Hiding","text":"
{\n  title: 'Gov. Levels',\n  responsive: ['md'],  // Hide on screens < 768px\n}\n

Benefits: - Mobile users see only essential columns (Title, Status, Actions) - Desktop users see full details - No horizontal scrolling on mobile

"},{"location":"v2/frontend/pages/admin/campaigns-page/#usecallback-memoization","title":"useCallback Memoization","text":"
const fetchCampaigns = useCallback(async (params) => {\n  // ... fetch logic\n}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]);\n

Benefits: - Function reference stable unless dependencies change - Prevents unnecessary re-renders in child components - Avoids infinite re-render loops

"},{"location":"v2/frontend/pages/admin/campaigns-page/#pagination","title":"Pagination","text":"

Default 20 items per page: - Keeps initial load fast - User can change page size (10, 20, 50, 100) - Server-side pagination (not loading all campaigns at once)

"},{"location":"v2/frontend/pages/admin/campaigns-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#mobile-576px","title":"Mobile (< 576px)","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#tablet-576px-992px","title":"Tablet (576px - 992px)","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#desktop-992px","title":"Desktop (\u2265 992px)","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#campaign-not-appearing-on-public-page","title":"Campaign Not Appearing on Public Page","text":"

Problem: Created campaign, set status to ACTIVE, but /campaigns page doesn't show it.

Diagnosis:

Check status in campaigns table:

campaigns.find((c) => c.slug === 'my-campaign')?.status  // Should be \"ACTIVE\"\n

Common Issues:

  1. Status still DRAFT:
  2. Edit campaign
  3. Change Status dropdown from DRAFT to ACTIVE
  4. Click Save

  5. Browser cache:

  6. Hard refresh public page (Ctrl+Shift+R)
  7. Or clear browser cache

  8. Campaign created but not saved:

  9. Check for error message after clicking Create
  10. Verify required fields filled (Title, Email Subject, Email Body)

Solution: Always verify status is ACTIVE after creating campaign. Status defaults to DRAFT.

"},{"location":"v2/frontend/pages/admin/campaigns-page/#emails-drawer-shows-0-emails","title":"Emails Drawer Shows 0 Emails","text":"

Problem: Click Mail icon for ACTIVE campaign, drawer shows 0 emails.

Diagnosis:

Campaign might be active but no one has sent emails yet:

campaign._count.emails === 0  // No emails sent via this campaign\n

Common Issues:

  1. Campaign just published:
  2. No users have accessed public page yet
  3. Share campaign link to supporters

  4. SMTP not configured:

  5. Check Settings \u2192 Email tab
  6. Verify Production SMTP credentials
  7. Test connection

  8. BullMQ queue not running:

  9. Check docker-compose logs: docker compose logs email-worker
  10. Verify redis container running

Solution: Emails drawer shows historical data. If campaign is new, wait for users to send emails via public page.

"},{"location":"v2/frontend/pages/admin/campaigns-page/#copy-link-button-not-working","title":"Copy Link Button Not Working","text":"

Problem: Click Link icon, no success message, clipboard empty.

Diagnosis:

Check browser console for errors:

DOMException: Document is not focused\n

Common Issue:

Browser security blocks clipboard access if page not focused.

Solution:

  1. Click anywhere on page to focus
  2. Retry Copy Link button
  3. Or manually copy slug from table: /campaign/{slug}
"},{"location":"v2/frontend/pages/admin/campaigns-page/#duplicate-campaign-titles","title":"Duplicate Campaign Titles","text":"

Problem: Create campaign with same title as existing, backend allows it.

Diagnosis:

Backend auto-generates unique slug by appending numbers:

\"Climate Action\" \u2192 \"climate-action\"\n\"Climate Action\" (duplicate) \u2192 \"climate-action-1\"\n\"Climate Action\" (duplicate 2) \u2192 \"climate-action-2\"\n

Not an error: Duplicate titles allowed, slugs remain unique.

Best Practice: Use unique, descriptive titles to avoid confusion: - \u274c \"Climate Action\" (generic) - \u2705 \"Climate Action: Support Bill C-12\" (specific)

"},{"location":"v2/frontend/pages/admin/campaigns-page/#delete-confirmation-not-showing","title":"Delete Confirmation Not Showing","text":"

Problem: Click Delete icon, campaign deletes immediately without confirmation.

Diagnosis:

Check Popconfirm placement in table Actions column:

<Popconfirm\n  title=\"Delete this campaign?\"\n  description=\"All associated emails and responses will also be deleted.\"\n  onConfirm={() => handleDelete(record.id)}\n>\n  <Button type=\"link\" size=\"small\" danger icon={<DeleteOutlined />} title=\"Delete\" />\n</Popconfirm>\n

Solution: Popconfirm wraps the Button. If Popconfirm missing, delete happens immediately. Always use Popconfirm for destructive actions.

"},{"location":"v2/frontend/pages/admin/campaigns-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/","title":"CanvassDashboardPage","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#overview","title":"Overview","text":"

The CanvassDashboardPage provides a real-time administrative overview of all volunteer canvassing activities across all cuts. It displays live statistics (total visits, active volunteers, active sessions), a chronological activity feed showing recent visit outcomes, cut-by-cut progress tracking with completion percentages, a volunteer leaderboard ranked by visit count, and an interactive map showing active volunteers' current GPS positions. The dashboard auto-refreshes every 30 seconds to maintain real-time accuracy.

Route: /app/canvass/dashboard Component: admin/src/pages/CanvassDashboardPage.tsx (316 lines) Auth Required: Yes (SUPER_ADMIN or MAP_ADMIN role recommended) Layout: AppLayout Backend Module: api/src/modules/map/canvass/

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#screenshot","title":"Screenshot","text":"

[Screenshot: CanvassDashboardPage with \"Canvass Dashboard\" title and refresh icon button. Below are four statistics cards in a row: \"Total Visits: 1,247\", \"Active Volunteers: 8\", \"Active Sessions: 5\", \"Avg Visits per Session: 23.7\". Below that is a two-column layout: left side has \"Recent Activity\" card with scrollable feed showing timestamped visit entries like \"John Doe - NOT_HOME (123 Main St) - 2 mins ago\"; right side has two stacked cards: \"Cut Progress\" showing progress bars for each cut with percentages, and \"Top Volunteers\" showing ranked list with visit counts. At bottom is full-width \"Live Volunteer Map\" card with Leaflet map showing blue circle markers for active volunteer positions, colored polygon overlays for cuts, and legend in bottom-right corner.]

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#monitoring-active-canvassing","title":"Monitoring Active Canvassing","text":"
  1. Navigate to /app/canvass/dashboard
  2. Page loads with initial data fetch
  3. View statistics cards (top row):
  4. Total Visits: All-time visit count across all cuts
  5. Active Volunteers: Currently signed in with active sessions
  6. Active Sessions: Currently ACTIVE sessions (not COMPLETED or ABANDONED)
  7. Avg Visits per Session: Total visits / total sessions
  8. Observe auto-refresh indicator:
  9. Page refreshes every 30 seconds
  10. No loading spinner (silent refresh)
  11. Data updates smoothly without UI flicker
  12. Monitor activity feed (left column):
  13. See most recent 20 visits
  14. Each entry shows: volunteer name, outcome, address, relative time
  15. Color-coded by outcome (Answered=green, Not Home=red, etc.)
  16. Auto-scrolls to top when new visits appear
  17. Track cut progress (right column, top):
  18. See all cuts with visit counts
  19. Progress bars show completion percentage
  20. Percentage calculated as (visits / locations) \u00d7 100%
  21. View volunteer leaderboard (right column, bottom):
  22. Top 10 volunteers by visit count
  23. Shows total visits per volunteer
  24. Ranked 1st to 10th place
  25. Use live map (bottom):
  26. See active volunteers as blue circle markers
  27. View cut polygons as colored overlays
  28. Zoom and pan to explore territory
  29. Hover over markers for volunteer name and current location
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#responding-to-activity","title":"Responding to Activity","text":"
  1. Notice new visit in activity feed (e.g., \"Jane Doe - ANSWERED - 456 Oak Ave - Just now\")
  2. Click cut name in progress section to view cut details:
  3. Navigates to /app/map/cuts?id={cutId}
  4. Opens CutsPage filtered to that cut
  5. Can view all locations, edit cut, or export data
  6. Click volunteer name in leaderboard to view volunteer details:
  7. Navigates to /app/users?id={userId}
  8. Opens UsersPage filtered to that user
  9. Can edit user info, assign roles, or view all visits
  10. Click refresh button (top-right, next to title) to force immediate data update:
  11. Fetches latest data from API
  12. Updates all sections simultaneously
  13. Useful when expecting urgent update (e.g., shift just ended)
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#identifying-issues","title":"Identifying Issues","text":"
  1. No Active Volunteers:
  2. Statistics show \"Active Volunteers: 0\"
  3. Activity feed is empty or stale
  4. Action: Check if any shifts are scheduled, contact volunteers to start shifts

  5. High \"Not Home\" Rate:

  6. Activity feed shows many red \"NOT_HOME\" entries
  7. Action: Consider rescheduling shifts to evening hours when residents more likely home

  8. Stalled Sessions:

  9. Active Sessions count doesn't decrease over time
  10. Action: Check for abandoned sessions (volunteers forgot to end session), manually close via backend

  11. Volunteers Off Course:

  12. Live map shows volunteer marker far from assigned cut polygon
  13. Action: Contact volunteer to redirect back to assigned territory

  14. Low Visits per Session:

  15. Average visits per session below expected rate (e.g., < 10)
  16. Action: Investigate if locations are too far apart, provide walking route optimization
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#using-live-map","title":"Using Live Map","text":"
  1. Scroll to \"Live Volunteer Map\" card at bottom
  2. Map loads with:
  3. Cut polygons as colored overlays (semi-transparent fill)
  4. Active volunteer markers as blue circles
  5. Legend in bottom-right corner
  6. Zoom controls:
  7. Plus (+) button: Zoom in
  8. Minus (\u2212) button: Zoom out
  9. Scroll wheel: Zoom in/out
  10. Pan:
  11. Click and drag map to move view
  12. Double-click to zoom in on point
  13. Marker interaction:
  14. Hover over blue marker: Tooltip shows volunteer name
  15. Click marker: Opens popup with volunteer details (name, current session, visit count)
  16. Polygon interaction:
  17. Hover over cut polygon: Tooltip shows cut name and visit count
  18. Click polygon: Navigates to cut details page
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#ant-design-components-used","title":"Ant Design Components Used","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#custom-components","title":"Custom Components","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#statistics-cards","title":"Statistics Cards","text":"
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>\n  <Col xs={24} sm={12} md={6}>\n    <Card>\n      <Statistic\n        title=\"Total Visits\"\n        value={stats.totalVisits}\n        prefix={<CheckCircleOutlined />}\n        valueStyle={{ color: '#3f8600' }}\n      />\n    </Card>\n  </Col>\n  <Col xs={24} sm={12} md={6}>\n    <Card>\n      <Statistic\n        title=\"Active Volunteers\"\n        value={stats.activeVolunteers}\n        prefix={<TeamOutlined />}\n        valueStyle={{ color: '#1890ff' }}\n      />\n    </Card>\n  </Col>\n  <Col xs={24} sm={12} md={6}>\n    <Card>\n      <Statistic\n        title=\"Active Sessions\"\n        value={stats.activeSessions}\n        prefix={<FieldTimeOutlined />}\n        valueStyle={{ color: '#faad14' }}\n      />\n    </Card>\n  </Col>\n  <Col xs={24} sm={12} md={6}>\n    <Card>\n      <Statistic\n        title=\"Avg Visits per Session\"\n        value={stats.avgVisitsPerSession}\n        precision={1}\n        prefix={<LineChartOutlined />}\n        valueStyle={{ color: '#722ed1' }}\n      />\n    </Card>\n  </Col>\n</Row>\n

Responsive Grid: - Mobile (xs, <576px): Stacked cards (24 columns = full width) - Tablet (sm, \u2265576px): 2 columns (12 columns each = 50% width) - Desktop (md, \u2265768px): 4 columns (6 columns each = 25% width)

Color-Coded Values: - Total Visits: Green (#3f8600) \u2014 success metric - Active Volunteers: Blue (#1890ff) \u2014 informational - Active Sessions: Orange (#faad14) \u2014 warning/attention - Avg Visits per Session: Purple (#722ed1) \u2014 analytical

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#activity-feed","title":"Activity Feed","text":"
<Card\n  title=\"Recent Activity\"\n  style={{ height: 400, overflow: 'auto' }}\n>\n  <List\n    dataSource={recentActivity}\n    renderItem={(activity) => (\n      <List.Item>\n        <Space direction=\"vertical\" size={0} style={{ width: '100%' }}>\n          <Space>\n            <Text strong>{activity.volunteerName}</Text>\n            <Tag color={getOutcomeColor(activity.outcome)}>\n              {formatOutcome(activity.outcome)}\n            </Tag>\n          </Space>\n          <Text type=\"secondary\" style={{ fontSize: 13 }}>\n            {activity.address}\n          </Text>\n          <Text type=\"secondary\" style={{ fontSize: 12 }}>\n            {formatRelativeTime(activity.timestamp)}\n          </Text>\n        </Space>\n      </List.Item>\n    )}\n  />\n</Card>\n

Activity Entry Structure: - Line 1: Volunteer name (bold) + Outcome tag (color-coded) - Line 2: Location address (secondary gray text) - Line 3: Relative timestamp (smaller secondary text)

Outcome Color Mapping:

function getOutcomeColor(outcome: string): string {\n  const colorMap: Record<string, string> = {\n    ANSWERED: 'green',\n    NOT_HOME: 'red',\n    MOVED: 'orange',\n    REFUSED: 'volcano',\n    INACCESSIBLE: 'default',\n    OTHER: 'blue',\n  };\n  return colorMap[outcome] || 'default';\n}\n

Relative Time Formatting:

function formatRelativeTime(timestamp: string): string {\n  const now = dayjs();\n  const visitTime = dayjs(timestamp);\n  const diffMinutes = now.diff(visitTime, 'minute');\n\n  if (diffMinutes < 1) return 'Just now';\n  if (diffMinutes < 60) return `${diffMinutes} min${diffMinutes > 1 ? 's' : ''} ago`;\n\n  const diffHours = now.diff(visitTime, 'hour');\n  if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;\n\n  const diffDays = now.diff(visitTime, 'day');\n  return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;\n}\n
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#cut-progress-section","title":"Cut Progress Section","text":"
<Card title=\"Cut Progress\" style={{ marginBottom: 16 }}>\n  <Space direction=\"vertical\" size=\"middle\" style={{ width: '100%' }}>\n    {cutProgress.map((cut) => (\n      <div key={cut.id}>\n        <Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 4 }}>\n          <Text strong>{cut.name}</Text>\n          <Text type=\"secondary\">\n            {cut.visitCount} / {cut.locationCount} locations\n          </Text>\n        </Space>\n        <Progress\n          percent={cut.percentage}\n          status={cut.percentage === 100 ? 'success' : 'active'}\n          strokeColor={cut.percentage === 100 ? '#52c41a' : '#1890ff'}\n        />\n      </div>\n    ))}\n  </Space>\n</Card>\n

Progress Calculation:

const percentage = Math.round((cut.visitCount / cut.locationCount) * 100);\n

Progress Bar States: - Active (< 100%): Blue bar, animated stripes - Success (100%): Green bar, checkmark icon - Empty (0%): Gray bar, no progress

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#volunteer-leaderboard","title":"Volunteer Leaderboard","text":"
<Card title=\"Top Volunteers\">\n  <List\n    dataSource={topVolunteers.slice(0, 10)}  // Top 10 only\n    renderItem={(volunteer, index) => (\n      <List.Item>\n        <Space>\n          <Text strong style={{ fontSize: 16 }}>\n            #{index + 1}\n          </Text>\n          <Text>{volunteer.name}</Text>\n        </Space>\n        <Tag color=\"blue\">{volunteer.visitCount} visits</Tag>\n      </List.Item>\n    )}\n  />\n</Card>\n

Ranking Display: - #1-3: Bold, larger font (emphasize top performers) - #4-10: Standard font - Visit count: Blue badge on right side

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#live-volunteer-map","title":"Live Volunteer Map","text":"
<Card title=\"Live Volunteer Map\" style={{ marginTop: 24 }}>\n  <div style={{ height: 500 }}>\n    <AdminMapView\n      cuts={cuts}\n      volunteers={activeVolunteers}\n      showCutOverlays={true}\n      showVolunteerMarkers={true}\n      autoCenter={true}\n    />\n  </div>\n</Card>\n

Map Features: - Height: Fixed 500px (provides adequate viewing area) - Auto-center: Automatically zooms to show all active volunteers - Cut overlays: Semi-transparent polygons with cut colors - Volunteer markers: Blue circles at current GPS position - Legend: Bottom-right corner explaining marker types

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#local-state-no-zustand-store","title":"Local State (No Zustand Store)","text":"
// Data state\nconst [stats, setStats] = useState<CanvassStats | null>(null);\nconst [recentActivity, setRecentActivity] = useState<CanvassVisit[]>([]);\nconst [cutProgress, setCutProgress] = useState<CutProgress[]>([]);\nconst [topVolunteers, setTopVolunteers] = useState<VolunteerStats[]>([]);\nconst [activeVolunteers, setActiveVolunteers] = useState<ActiveVolunteer[]>([]);\nconst [cuts, setCuts] = useState<Cut[]>([]);\nconst [loading, setLoading] = useState(true);\n

No Global State:

This page does NOT use Zustand stores. All data is fetched directly from the API and stored in local state. This is appropriate because: - Dashboard data is admin-only (not shared with other pages) - Data is highly dynamic (changes every 30 seconds) - No need to persist data between page visits - Simpler architecture without store overhead

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#auto-refresh-with-useeffect","title":"Auto-Refresh with useEffect","text":"
const loadData = useCallback(async () => {\n  try {\n    // Fetch all data in parallel\n    const [statsRes, activityRes, progressRes, volunteersRes, cutsRes, activePosRes] = await Promise.all([\n      api.get<CanvassStats>('/canvass/admin/stats'),\n      api.get<CanvassVisit[]>('/canvass/admin/recent-activity?limit=20'),\n      api.get<CutProgress[]>('/canvass/admin/cut-progress'),\n      api.get<VolunteerStats[]>('/canvass/admin/top-volunteers?limit=10'),\n      api.get<Cut[]>('/cuts'),\n      api.get<ActiveVolunteer[]>('/canvass/admin/active-volunteers'),\n    ]);\n\n    setStats(statsRes.data);\n    setRecentActivity(activityRes.data);\n    setCutProgress(progressRes.data);\n    setTopVolunteers(volunteersRes.data);\n    setCuts(cutsRes.data);\n    setActiveVolunteers(activePosRes.data);\n  } catch (error) {\n    message.error('Failed to load dashboard data');\n  } finally {\n    setLoading(false);\n  }\n}, []);\n\nuseEffect(() => {\n  // Initial load\n  loadData();\n\n  // Set up auto-refresh interval\n  const interval = setInterval(loadData, 30000);  // Refresh every 30 seconds\n\n  // Cleanup on unmount\n  return () => clearInterval(interval);\n}, [loadData]);\n

Auto-Refresh Strategy:

Why 30 Seconds?

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#usecallback-optimization","title":"useCallback Optimization","text":"
const loadData = useCallback(async () => {\n  // ... fetch logic\n}, []);\n\nuseEffect(() => {\n  loadData();\n  const interval = setInterval(loadData, 30000);\n  return () => clearInterval(interval);\n}, [loadData]);\n

Why useCallback?

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose Auth GET /api/canvass/admin/stats Overall statistics Required (ADMIN) GET /api/canvass/admin/recent-activity Recent 20 visits Required (ADMIN) GET /api/canvass/admin/cut-progress Cut-by-cut progress Required (ADMIN) GET /api/canvass/admin/top-volunteers Volunteer leaderboard Required (ADMIN) GET /api/canvass/admin/active-volunteers Live volunteer positions Required (ADMIN) GET /api/cuts All cuts for map Required"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#load-overall-statistics","title":"Load Overall Statistics","text":"

Request:

const { data } = await api.get<CanvassStats>('/canvass/admin/stats');\n

Response (200 OK):

{\n  \"totalVisits\": 1247,\n  \"activeVolunteers\": 8,\n  \"activeSessions\": 5,\n  \"avgVisitsPerSession\": 23.7,\n  \"breakdown\": {\n    \"ANSWERED\": 523,\n    \"NOT_HOME\": 412,\n    \"MOVED\": 89,\n    \"REFUSED\": 156,\n    \"INACCESSIBLE\": 45,\n    \"OTHER\": 22\n  }\n}\n

Response Fields: - totalVisits (number): All-time visit count across all sessions - activeVolunteers (number): Currently signed in with ACTIVE sessions - activeSessions (number): Sessions with status = ACTIVE (not COMPLETED or ABANDONED) - avgVisitsPerSession (number): Total visits / total sessions (decimal) - breakdown (object): Visit count by outcome type

Backend Calculation:

const totalVisits = await prisma.canvassVisit.count();\n\nconst activeSessions = await prisma.canvassSession.count({\n  where: { status: 'ACTIVE' },\n});\n\nconst activeVolunteers = await prisma.canvassSession.findMany({\n  where: { status: 'ACTIVE' },\n  distinct: ['userId'],\n});\n\nconst allSessions = await prisma.canvassSession.count();\nconst avgVisitsPerSession = allSessions > 0 ? totalVisits / allSessions : 0;\n\nconst breakdown = await prisma.canvassVisit.groupBy({\n  by: ['outcome'],\n  _count: { id: true },\n});\n
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#load-recent-activity","title":"Load Recent Activity","text":"

Request:

const { data } = await api.get<CanvassVisit[]>('/canvass/admin/recent-activity', {\n  params: { limit: 20 },\n});\n

Query Parameters: - limit (number, optional): Maximum number of visits to return (default: 20)

Response (200 OK):

[\n  {\n    \"id\": \"visit_abc123\",\n    \"outcome\": \"ANSWERED\",\n    \"address\": \"456 Oak Avenue\",\n    \"timestamp\": \"2026-02-11T14:23:15.000Z\",\n    \"volunteerName\": \"Jane Doe\",\n    \"locationId\": \"loc_def456\",\n    \"sessionId\": \"session_ghi789\"\n  },\n  {\n    \"id\": \"visit_jkl012\",\n    \"outcome\": \"NOT_HOME\",\n    \"address\": \"123 Main Street\",\n    \"timestamp\": \"2026-02-11T14:18:42.000Z\",\n    \"volunteerName\": \"John Smith\",\n    \"locationId\": \"loc_mno345\",\n    \"sessionId\": \"session_pqr678\"\n  }\n]\n

Response Fields: - id (string): Unique visit identifier - outcome (string): Visit outcome (ANSWERED, NOT_HOME, MOVED, REFUSED, INACCESSIBLE, OTHER) - address (string): Location address - timestamp (ISO 8601): Visit timestamp - volunteerName (string): Name of volunteer who recorded visit - locationId (string): Associated location ID - sessionId (string): Associated canvass session ID

Sorting: - Ordered by timestamp DESC (most recent first)

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#load-cut-progress","title":"Load Cut Progress","text":"

Request:

const { data } = await api.get<CutProgress[]>('/canvass/admin/cut-progress');\n

Response (200 OK):

[\n  {\n    \"id\": \"cut_abc123\",\n    \"name\": \"Downtown Core\",\n    \"locationCount\": 157,\n    \"visitCount\": 89,\n    \"percentage\": 57\n  },\n  {\n    \"id\": \"cut_def456\",\n    \"name\": \"Riverside District\",\n    \"locationCount\": 203,\n    \"visitCount\": 203,\n    \"percentage\": 100\n  },\n  {\n    \"id\": \"cut_ghi789\",\n    \"name\": \"Suburban Area\",\n    \"locationCount\": 312,\n    \"visitCount\": 0,\n    \"percentage\": 0\n  }\n]\n

Response Fields: - id (string): Cut identifier - name (string): Cut name - locationCount (number): Total locations in cut - visitCount (number): Number of locations with at least one visit - percentage (number): Completion percentage (rounded to integer)

Percentage Calculation:

// Backend calculation\nconst percentage = Math.round((visitCount / locationCount) * 100);\n

Important: A location is counted as \"visited\" if it has at least one CanvassVisit record, regardless of outcome. Multiple visits to same location don't increase count.

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#load-top-volunteers","title":"Load Top Volunteers","text":"

Request:

const { data } = await api.get<VolunteerStats[]>('/canvass/admin/top-volunteers', {\n  params: { limit: 10 },\n});\n

Query Parameters: - limit (number, optional): Maximum number of volunteers to return (default: 10)

Response (200 OK):

[\n  {\n    \"id\": \"user_abc123\",\n    \"name\": \"Jane Doe\",\n    \"email\": \"jane.doe@example.com\",\n    \"visitCount\": 347,\n    \"sessionCount\": 15,\n    \"avgVisitsPerSession\": 23.1\n  },\n  {\n    \"id\": \"user_def456\",\n    \"name\": \"John Smith\",\n    \"email\": \"john.smith@example.com\",\n    \"visitCount\": 289,\n    \"sessionCount\": 12,\n    \"avgVisitsPerSession\": 24.1\n  },\n  {\n    \"id\": \"user_ghi789\",\n    \"name\": \"Bob Johnson\",\n    \"email\": \"bob.johnson@example.com\",\n    \"visitCount\": 201,\n    \"sessionCount\": 8,\n    \"avgVisitsPerSession\": 25.1\n  }\n]\n

Response Fields: - id (string): User identifier - name (string): Volunteer full name - email (string): Volunteer email - visitCount (number): Total visits recorded by volunteer (all-time) - sessionCount (number): Total sessions completed by volunteer - avgVisitsPerSession (number): Visits / sessions (decimal)

Sorting: - Ordered by visitCount DESC (highest visit count first) - Limited to top N volunteers (default 10)

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#load-active-volunteers","title":"Load Active Volunteers","text":"

Request:

const { data } = await api.get<ActiveVolunteer[]>('/canvass/admin/active-volunteers');\n

Response (200 OK):

[\n  {\n    \"id\": \"user_abc123\",\n    \"name\": \"Jane Doe\",\n    \"sessionId\": \"session_ghi789\",\n    \"cutId\": \"cut_jkl012\",\n    \"cutName\": \"Downtown Core\",\n    \"latitude\": 45.42153,\n    \"longitude\": -75.69602,\n    \"lastUpdate\": \"2026-02-11T14:25:30.000Z\",\n    \"visitCount\": 12\n  },\n  {\n    \"id\": \"user_def456\",\n    \"name\": \"John Smith\",\n    \"sessionId\": \"session_mno345\",\n    \"cutId\": \"cut_pqr678\",\n    \"cutName\": \"Riverside District\",\n    \"latitude\": 45.43264,\n    \"longitude\": -75.70813,\n    \"lastUpdate\": \"2026-02-11T14:24:15.000Z\",\n    \"visitCount\": 8\n  }\n]\n

Response Fields: - id (string): User identifier - name (string): Volunteer full name - sessionId (string): Active session identifier - cutId (string): Assigned cut identifier - cutName (string): Assigned cut name - latitude (number): Current GPS latitude - longitude (number): Current GPS longitude - lastUpdate (ISO 8601): Last GPS position update timestamp - visitCount (number): Visits recorded in current session

Filtering: - Only includes volunteers with status = ACTIVE sessions - GPS position from most recent TrackPoint record - Excludes volunteers with null GPS coordinates

Backend Query:

const activeSessions = await prisma.canvassSession.findMany({\n  where: { status: 'ACTIVE' },\n  include: {\n    user: true,\n    cut: true,\n    visits: true,\n    trackPoints: {\n      orderBy: { timestamp: 'desc' },\n      take: 1,  // Most recent track point\n    },\n  },\n});\n\nconst activeVolunteers = activeSessions\n  .filter((session) => session.trackPoints.length > 0)\n  .map((session) => ({\n    id: session.userId,\n    name: session.user.name,\n    sessionId: session.id,\n    cutId: session.cutId,\n    cutName: session.cut?.name || 'Unknown',\n    latitude: session.trackPoints[0].latitude,\n    longitude: session.trackPoints[0].longitude,\n    lastUpdate: session.trackPoints[0].timestamp,\n    visitCount: session.visits.length,\n  }));\n
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#complete-data-loading-flow","title":"Complete Data Loading Flow","text":"
const loadData = useCallback(async () => {\n  try {\n    // Fetch all dashboard data in parallel (6 API calls)\n    const [statsRes, activityRes, progressRes, volunteersRes, cutsRes, activePosRes] = await Promise.all([\n      api.get<CanvassStats>('/canvass/admin/stats'),\n      api.get<CanvassVisit[]>('/canvass/admin/recent-activity', { params: { limit: 20 } }),\n      api.get<CutProgress[]>('/canvass/admin/cut-progress'),\n      api.get<VolunteerStats[]>('/canvass/admin/top-volunteers', { params: { limit: 10 } }),\n      api.get<Cut[]>('/cuts'),\n      api.get<ActiveVolunteer[]>('/canvass/admin/active-volunteers'),\n    ]);\n\n    // Update all state simultaneously\n    setStats(statsRes.data);\n    setRecentActivity(activityRes.data);\n    setCutProgress(progressRes.data);\n    setTopVolunteers(volunteersRes.data);\n    setCuts(cutsRes.data);\n    setActiveVolunteers(activePosRes.data);\n  } catch (error) {\n    if (axios.isAxiosError(error) && error.response?.status === 401) {\n      message.error('Authentication expired. Please log in again.');\n    } else {\n      message.error('Failed to load dashboard data');\n    }\n  } finally {\n    setLoading(false);\n  }\n}, []);\n

Parallel Fetching Benefits: - Faster load time: 6 requests execute simultaneously, not sequentially - Without parallel: 6 \u00d7 200ms average = 1,200ms total load time - With parallel: max(200ms) = 200ms total load time (6\u00d7 faster) - Atomic updates: All state updates happen together (no partial UI updates)

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#auto-refresh-setup","title":"Auto-Refresh Setup","text":"
useEffect(() => {\n  // Initial load on mount\n  loadData();\n\n  // Set up 30-second auto-refresh interval\n  const interval = setInterval(loadData, 30000);\n\n  // Cleanup interval on unmount (prevents memory leak)\n  return () => {\n    clearInterval(interval);\n    console.log('Dashboard auto-refresh stopped');\n  };\n}, [loadData]);\n

Cleanup Importance:

If interval is not cleared on unmount: - Memory leak (interval continues running in background) - API calls continue even after user navigates away - Multiple overlapping intervals if user returns to page

Testing Auto-Refresh:

// Mock API responses changing over time\nconst mockStats = {\n  totalVisits: 1247 + Math.floor(Math.random() * 10),  // Increases by 0-10 each refresh\n  activeVolunteers: 8,\n  activeSessions: 5,\n  avgVisitsPerSession: 23.7,\n};\n
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#manual-refresh-handler","title":"Manual Refresh Handler","text":"
const handleRefresh = async () => {\n  message.loading('Refreshing dashboard data...', 0);  // Indefinite loading message\n  try {\n    await loadData();\n    message.destroy();  // Clear loading message\n    message.success('Dashboard refreshed');\n  } catch (error) {\n    message.destroy();\n    message.error('Failed to refresh dashboard');\n  }\n};\n\n// Refresh button in header\n<Button\n  type=\"text\"\n  icon={<ReloadOutlined />}\n  onClick={handleRefresh}\n  style={{ marginLeft: 8 }}\n>\n  Refresh\n</Button>\n

Manual Refresh Use Cases: - User expects immediate update after completing action (e.g., ending shift) - 30-second auto-refresh feels too slow for urgent update - User wants to verify data accuracy

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#relative-time-formatting","title":"Relative Time Formatting","text":"
function formatRelativeTime(timestamp: string): string {\n  const now = dayjs();\n  const visitTime = dayjs(timestamp);\n\n  const diffMinutes = now.diff(visitTime, 'minute');\n  if (diffMinutes < 1) return 'Just now';\n  if (diffMinutes === 1) return '1 min ago';\n  if (diffMinutes < 60) return `${diffMinutes} mins ago`;\n\n  const diffHours = now.diff(visitTime, 'hour');\n  if (diffHours === 1) return '1 hour ago';\n  if (diffHours < 24) return `${diffHours} hours ago`;\n\n  const diffDays = now.diff(visitTime, 'day');\n  if (diffDays === 1) return '1 day ago';\n  if (diffDays < 7) return `${diffDays} days ago`;\n\n  // For older visits, show absolute date\n  return visitTime.format('MMM D, h:mm A');\n}\n

Examples: - \"Just now\" (< 1 minute) - \"1 min ago\" (exactly 1 minute) - \"5 mins ago\" (5 minutes) - \"1 hour ago\" (exactly 1 hour) - \"3 hours ago\" (3 hours) - \"1 day ago\" (exactly 1 day) - \"5 days ago\" (5 days) - \"Jan 25, 2:30 PM\" (> 7 days)

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#outcome-color-mapping","title":"Outcome Color Mapping","text":"
function getOutcomeColor(outcome: string): string {\n  const colorMap: Record<string, string> = {\n    ANSWERED: 'green',       // Success: resident answered door\n    NOT_HOME: 'red',         // Fail: no answer\n    MOVED: 'orange',         // Warning: resident moved away\n    REFUSED: 'volcano',      // Fail: resident refused to engage\n    INACCESSIBLE: 'default', // Neutral: location inaccessible\n    OTHER: 'blue',           // Info: other outcome\n  };\n  return colorMap[outcome] || 'default';\n}\n\nfunction formatOutcome(outcome: string): string {\n  const labelMap: Record<string, string> = {\n    ANSWERED: 'Answered',\n    NOT_HOME: 'Not Home',\n    MOVED: 'Moved',\n    REFUSED: 'Refused',\n    INACCESSIBLE: 'Inaccessible',\n    OTHER: 'Other',\n  };\n  return labelMap[outcome] || outcome;\n}\n

Semantic Colors: - Green (Answered): Positive outcome, resident engaged - Red (Not Home): Negative outcome, wasted visit - Orange (Moved): Warning, location needs update - Volcano/Red (Refused): Negative, hostile interaction - Gray (Inaccessible): Neutral, infrastructure issue - Blue (Other): Informational, miscellaneous

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#parallel-api-requests","title":"Parallel API Requests","text":"

Dashboard loads 6 API endpoints simultaneously:

const [statsRes, activityRes, progressRes, volunteersRes, cutsRes, activePosRes] = await Promise.all([\n  api.get('/canvass/admin/stats'),\n  api.get('/canvass/admin/recent-activity'),\n  api.get('/canvass/admin/cut-progress'),\n  api.get('/canvass/admin/top-volunteers'),\n  api.get('/cuts'),\n  api.get('/canvass/admin/active-volunteers'),\n]);\n

Performance Comparison:

Sequential Fetching (bad):

const statsRes = await api.get('/stats');           // 200ms\nconst activityRes = await api.get('/activity');     // 200ms\nconst progressRes = await api.get('/progress');     // 200ms\nconst volunteersRes = await api.get('/volunteers'); // 200ms\nconst cutsRes = await api.get('/cuts');             // 200ms\nconst activePosRes = await api.get('/positions');   // 200ms\n// Total: 1,200ms\n

Parallel Fetching (good):

const allResults = await Promise.all([...]);  // max(200ms) = 200ms\n// Total: 200ms (6\u00d7 faster)\n

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#silent-auto-refresh","title":"Silent Auto-Refresh","text":"

Auto-refresh doesn't show loading spinner:

const loadData = useCallback(async () => {\n  // No setLoading(true) here for silent refresh\n  try {\n    const results = await Promise.all([...]);\n    // Update state without UI flicker\n  } catch (error) {\n    // Error handling without disrupting UX\n  }\n  // No finally setLoading(false)\n}, []);\n

Benefits: - No UI flicker: Dashboard doesn't flash every 30 seconds - Better UX: User can continue reading data during refresh - Smooth updates: Data changes appear naturally without distraction

Trade-off:

User doesn't see loading indicator, so may not know if data is stale. Mitigation: - Show \"Last updated: 15 seconds ago\" timestamp - Add refresh icon that spins during update - Use Ant Design Skeleton for shimmer effect

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#limited-data-sets","title":"Limited Data Sets","text":"

API endpoints return limited data:

Benefits: - Reduced payload: Smaller API responses = faster load times - Faster rendering: React renders 20 list items faster than 1,247 - Manageable UI: User can process 20 recent visits; 1,247 would be overwhelming

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#usecallback-memoization","title":"useCallback Memoization","text":"

Fetch function is memoized to prevent re-creation:

const loadData = useCallback(async () => {\n  // ... fetch logic\n}, []);  // Empty dependency array = function never re-created\n

Without useCallback:

const loadData = async () => { /* ... */ };\n\nuseEffect(() => {\n  loadData();\n  const interval = setInterval(loadData, 30000);\n  return () => clearInterval(interval);\n}, [loadData]);  // loadData changes every render \u2192 infinite loop\n

With useCallback:

const loadData = useCallback(async () => { /* ... */ }, []);\n\nuseEffect(() => {\n  loadData();\n  const interval = setInterval(loadData, 30000);\n  return () => clearInterval(interval);\n}, [loadData]);  // loadData stable \u2192 effect runs once\n

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#mobile-layout","title":"Mobile Layout","text":"

Dashboard adapts to mobile viewports:

Statistics Cards:

<Row gutter={[16, 16]}>\n  <Col xs={24} sm={12} md={6}>  {/* Full width mobile, half tablet, quarter desktop */}\n    <Card><Statistic title=\"Total Visits\" value={1247} /></Card>\n  </Col>\n  {/* Repeat for other cards */}\n</Row>\n

Two-Column Layout:

<Row gutter={[16, 16]}>\n  <Col xs={24} lg={12}>  {/* Full width mobile, half desktop */}\n    <Card title=\"Recent Activity\">{/* ... */}</Card>\n  </Col>\n  <Col xs={24} lg={12}>  {/* Full width mobile, half desktop */}\n    <Space direction=\"vertical\" style={{ width: '100%' }}>\n      <Card title=\"Cut Progress\">{/* ... */}</Card>\n      <Card title=\"Top Volunteers\">{/* ... */}</Card>\n    </Space>\n  </Col>\n</Row>\n

Responsive Breakpoints: - xs (mobile, <576px): Stacked layout (all cards full-width) - sm (tablet, \u2265576px): 2-column statistics cards - md (small desktop, \u2265768px): 4-column statistics cards - lg (large desktop, \u2265992px): 2-column main layout (activity left, progress/leaderboard right)

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#map-height","title":"Map Height","text":"

Map adapts to viewport height:

<Card title=\"Live Volunteer Map\">\n  <div style={{ height: 500, minHeight: 300 }}>\n    <AdminMapView {...props} />\n  </div>\n</Card>\n

Responsive Heights: - Desktop: 500px fixed height - Tablet: 400px (less vertical space) - Mobile: 300px minimum height (prevent squishing)

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#keyboard-navigation","title":"Keyboard Navigation","text":"

All interactive elements are keyboard-accessible:

Refresh Button: - Tab: Focus on refresh button - Enter/Space: Trigger refresh

Activity Feed: - Tab: Focus on list items - Enter: Activate clickable items (volunteer name, location) - Arrow Keys: Scroll list (native browser behavior)

Map: - Tab: Focus on map container - Arrow Keys: Pan map - +/\u2212: Zoom in/out - Enter: Activate focused marker

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#screen-reader-support","title":"Screen Reader Support","text":"

All elements have proper ARIA labels:

Statistics Cards:

<Statistic\n  title=\"Total Visits\"\n  value={stats.totalVisits}\n  aria-label={`Total visits: ${stats.totalVisits}`}\n/>\n

Activity Feed:

<List\n  aria-label=\"Recent canvassing activity\"\n  dataSource={recentActivity}\n  renderItem={(activity) => (\n    <List.Item aria-label={`${activity.volunteerName} recorded ${activity.outcome} at ${activity.address}`}>\n      {/* ... */}\n    </List.Item>\n  )}\n/>\n

Progress Bars:

<Progress\n  percent={cut.percentage}\n  aria-label={`${cut.name} progress: ${cut.percentage}% complete`}\n/>\n

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#color-contrast","title":"Color Contrast","text":"

All color-coded elements meet WCAG AA standards:

Outcome Tags: - Green (ANSWERED): #52c41a on white = 3.0:1 contrast (AA for large text) - Red (NOT_HOME): #f5222d on white = 4.5:1 contrast (AA) - Orange (MOVED): #fa8c16 on white = 3.3:1 contrast (AA for large text)

Statistics Values: - Green: #3f8600 on white = 4.8:1 contrast (AA) - Blue: #1890ff on white = 4.5:1 contrast (AA) - Orange: #faad14 on white = 3.2:1 contrast (AA for large text)

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#dashboard-not-auto-refreshing","title":"Dashboard Not Auto-Refreshing","text":"

Problem: Dashboard loads initially, but data doesn't update after 30 seconds.

Diagnosis:

Check if interval is set up correctly:

useEffect(() => {\n  loadData();\n  const interval = setInterval(loadData, 30000);\n  return () => clearInterval(interval);\n}, [loadData]);\n

Open browser console and check for errors:

// Expected: No errors every 30 seconds\n// If errors appear every 30 seconds, auto-refresh is running but failing\n

Possible Causes:

  1. Interval not set up:
  2. Missing setInterval call
  3. Interval not returned from useEffect

  4. Interval cleared prematurely:

  5. Component unmounted and remounted (React Strict Mode in development)
  6. Cleanup function called too early

  7. API errors silently failing:

  8. Backend API down, but error not shown to user
  9. JWT token expired, 401 errors swallowed by try/catch

Solution:

  1. Verify interval exists:
  2. Add console.log in loadData: console.log('Dashboard refresh:', new Date())
  3. Check console every 30 seconds for log message

  4. Handle React Strict Mode:

  5. Accept that development mode unmounts/remounts components
  6. Ensure production build works correctly (no double mounting)

  7. Show API errors:

  8. Remove generic try/catch error handling
  9. Let errors bubble up to user as message.error()
  10. Add retry logic for transient failures
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#active-volunteers-0-despite-active-sessions","title":"\"Active Volunteers: 0\" Despite Active Sessions","text":"

Problem: Statistics show \"Active Volunteers: 0\" but shifts are scheduled and volunteers are canvassing.

Diagnosis:

Check active sessions in database:

SELECT COUNT(*) FROM \"CanvassSession\" WHERE status = 'ACTIVE';\n-- Expected: > 0 if volunteers are active\n-- Actual: 0 (sessions not marked as ACTIVE)\n

Possible Causes:

  1. Sessions not started:
  2. Volunteers signed up for shifts but didn't start canvassing
  3. No sessions with status = ACTIVE

  4. Sessions abandoned:

  5. Volunteers forgot to end sessions, sessions auto-closed by backend
  6. Sessions marked as ABANDONED instead of ACTIVE

  7. Sessions completed:

  8. Volunteers ended sessions, now showing as COMPLETED
  9. Active count only includes ACTIVE status

Solution:

  1. Contact volunteers:
  2. Ask them to start canvassing session from volunteer portal
  3. Navigate to /volunteer/assignments, click \"Start Canvassing\"

  4. Check abandoned sessions:

  5. Navigate to Canvass Dashboard
  6. Look for sessions with \"ABANDONED\" status
  7. Manually reopen if volunteer is still active

  8. Adjust status query:

  9. If volunteers frequently forget to end sessions, consider showing ACTIVE + recently updated sessions (< 1 hour ago)
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#map-not-showing-volunteer-markers","title":"Map Not Showing Volunteer Markers","text":"

Problem: Live Volunteer Map loads but shows no blue markers, even though \"Active Volunteers: 8\".

Diagnosis:

Check active volunteers API response:

const { data } = await api.get('/canvass/admin/active-volunteers');\nconsole.log('Active volunteers:', data);\n// Expected: Array with 8 volunteers\n// Actual: Empty array or volunteers without GPS coordinates\n

Possible Causes:

  1. No GPS tracking enabled:
  2. Volunteers have active sessions but GPS tracking not enabled
  3. No TrackPoint records exist for sessions

  4. Null GPS coordinates:

  5. TrackPoint records exist but latitude/longitude are null
  6. Backend filters out volunteers without valid coordinates

  7. Map zoom level:

  8. Volunteers outside current map viewport
  9. Auto-center not working correctly

Solution:

  1. Enable GPS tracking:
  2. Ensure volunteers grant location permissions in browser
  3. Check volunteer portal GPS tracker is running
  4. Navigate to /volunteer/canvass/:cutId, verify \"GPS Active\" indicator

  5. Check GPS permissions:

  6. Ask volunteers to enable location services in browser settings
  7. Chrome: Settings \u2192 Privacy \u2192 Site Settings \u2192 Location \u2192 Allow
  8. Safari: Preferences \u2192 Websites \u2192 Location \u2192 Allow

  9. Zoom out on map:

  10. Click zoom out (\u2212) button several times
  11. See if markers appear outside initial viewport
  12. If yes, auto-center logic is broken (should zoom to fit all markers)
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#progress-percentages-over-100","title":"Progress Percentages Over 100%","text":"

Problem: Cut Progress section shows \"Downtown Core: 157 / 150 locations (105%)\".

Diagnosis:

Check location count vs. visit count:

-- Count locations in cut\nSELECT COUNT(*) FROM \"Location\" WHERE \"cutId\" = 'cut_abc123';\n-- Result: 150\n\n-- Count unique locations with visits\nSELECT COUNT(DISTINCT \"locationId\") FROM \"CanvassVisit\"\nWHERE \"locationId\" IN (\n  SELECT id FROM \"Location\" WHERE \"cutId\" = 'cut_abc123'\n);\n-- Result: 157 (more than location count!)\n

Possible Causes:

  1. Locations moved out of cut:
  2. Locations visited while in cut, then unassigned from cut
  3. Visit records still reference old cutId, inflating count

  4. Duplicate visits counted:

  5. Multiple visits to same location counted separately
  6. Should count unique locations, not total visits

  7. Backend calculation bug:

  8. Visit count not filtered by current cut membership
  9. Includes visits to locations now in different cuts

Solution:

  1. Fix backend query:
  2. Only count visits to locations currently in cut:

    const visitCount = await prisma.canvassVisit.count({\n  where: {\n    location: {\n      cutId: cut.id,\n      deletedAt: null,\n    },\n  },\n  distinct: ['locationId'],  // Count unique locations only\n});\n

  3. Cap percentage at 100%:

  4. Frontend safety check:

    const percentage = Math.min(100, Math.round((visitCount / locationCount) * 100));\n

  5. Investigate data integrity:

  6. Find orphaned visits:
    SELECT * FROM \"CanvassVisit\"\nWHERE \"locationId\" NOT IN (SELECT id FROM \"Location\");\n
  7. Delete orphaned visits or reassociate with correct locations
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/","title":"CodeEditorPage","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#overview","title":"Overview","text":"

File: admin/src/pages/CodeEditorPage.tsx

Route: /app/services/code-editor

Role Requirements: Any authenticated user (uses authenticate middleware)

Purpose: Provides an embedded interface to the Code Server (VS Code in browser) via iframe. Code Server is a web-based IDE that runs Visual Studio Code in the browser, allowing developers to edit code, manage files, and run terminal commands directly from the admin interface. This page serves as a wrapper that embeds Code Server with online/offline status monitoring and mobile device detection.

Key Features: - Full-page iframe embed of Code Server service - Service online/offline status monitoring with Badge - Mobile device detection with warning screen - \"Refresh\" button to re-check service status - \"Open in New Tab\" button for external access - Fullbleed layout (no padding in AppLayout) - Automatic service health checks via API

Layout: AppLayout with fullbleed (no content padding)

Dependencies: - Ant Design v5 (Button, Space, Badge, Spin, Grid, Result) - react-router-dom (useOutletContext)

"},{"location":"v2/frontend/pages/admin/code-editor-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#1-service-status-monitoring","title":"1. Service Status Monitoring","text":"

Status Display: - Green \"Online\" badge when Code Server is accessible - Red \"Offline\" badge when Code Server is not accessible - Blue \"Checking...\" badge during status check - Badge displayed in page header

"},{"location":"v2/frontend/pages/admin/code-editor-page/#2-mobile-device-detection","title":"2. Mobile Device Detection","text":"

Mobile Warning Screen: - Detects mobile devices using Grid.useBreakpoint() - Shows warning Result component on mobile - Recommends using desktop for code editing - Icon: CodeOutlined (48px)

Breakpoint: !screens.md (screen width < 768px = mobile)

"},{"location":"v2/frontend/pages/admin/code-editor-page/#3-code-server-url-construction","title":"3. Code Server URL Construction","text":"

URL Building: - Fetches docs config from API (/api/docs/config) - Builds URL using codeServerPort configuration - Uses hostname + port pattern - Example: http://localhost:8888 or http://code.cmlite.org

"},{"location":"v2/frontend/pages/admin/code-editor-page/#4-iframe-embedding","title":"4. Iframe Embedding","text":"

Fullbleed Layout: - No padding around iframe - Height: calc(100vh - 64px) (full viewport height minus header) - Width: 100% - No border for seamless VS Code integration

"},{"location":"v2/frontend/pages/admin/code-editor-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#accessing-code-server","title":"Accessing Code Server","text":"
  1. Navigate to Code Editor:
  2. Click \"Services\" \u2192 \"Code Editor\" in sidebar
  3. Page loads with status check

  4. Check Service Status:

  5. Status badge appears in page header:

  6. View on Desktop:

  7. If on desktop (screen width \u2265 768px):

  8. View on Mobile:

  9. If on mobile (screen width < 768px):

  10. Using Code Server:

  11. File Explorer: Browse project files in sidebar
  12. Editor: Edit code with syntax highlighting, IntelliSense
  13. Terminal: Run bash commands (npm, git, docker)
  14. Extensions: Install VS Code extensions
  15. Search: Global file search (Ctrl+P)
  16. Git: Source control integration

  17. Common Tasks:

  18. Edit API routes: /api/src/modules/
  19. Edit admin pages: /admin/src/pages/
  20. Run migrations: Terminal \u2192 cd api && npx prisma migrate dev
  21. Start dev servers: Terminal \u2192 npm run dev
  22. View logs: Terminal \u2192 docker compose logs -f api
"},{"location":"v2/frontend/pages/admin/code-editor-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#main-component-structure","title":"Main Component Structure","text":"
export default function CodeEditorPage() {\n  const { setPageHeader } = useOutletContext<AppOutletContext>();\n  const screens = Grid.useBreakpoint();\n  const isMobile = !screens.md;\n\n  const [online, setOnline] = useState<boolean | null>(null);\n  const [codeServerPort, setCodeServerPort] = useState<number | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  // Fetch service status and config\n  const fetchStatus = useCallback(async () => {\n    try {\n      const [statusRes, configRes] = await Promise.all([\n        api.get<DocsStatus>('/docs/status'),\n        api.get<DocsConfig>('/docs/config'),\n      ]);\n      setOnline(statusRes.data.codeServer.online);\n      setCodeServerPort(configRes.data.codeServerPort);\n    } catch {\n      setOnline(false);\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    fetchStatus();\n  }, [fetchStatus]);\n\n  // Build service URL\n  const codeServerUrl = codeServerPort\n    ? `//${window.location.hostname}:${codeServerPort}`\n    : null;\n\n  // Page header with status badge and actions\n  const headerActions = useMemo(() => (\n    <Space>\n      <Badge\n        status={online === null ? 'processing' : online ? 'success' : 'error'}\n        text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}\n      />\n      <Button icon={<ReloadOutlined />} onClick={fetchStatus} size=\"small\">\n        Refresh\n      </Button>\n      {codeServerUrl && (\n        <Button icon={<LinkOutlined />} href={codeServerUrl} target=\"_blank\" size=\"small\">\n          Open in New Tab\n        </Button>\n      )}\n    </Space>\n  ), [online, fetchStatus, codeServerUrl]);\n\n  useEffect(() => {\n    setPageHeader({ title: 'Code Editor', actions: headerActions, fullBleed: true });\n    return () => setPageHeader(null);\n  }, [setPageHeader, headerActions]);\n\n  // Mobile warning\n  if (isMobile) {\n    return (\n      <Result\n        status=\"info\"\n        title=\"Desktop Required\"\n        subTitle=\"The code editor requires a desktop browser with a larger screen.\"\n        icon={<CodeOutlined style={{ fontSize: 48 }} />}\n      />\n    );\n  }\n\n  // Loading state\n  if (loading) {\n    return (\n      <div style={{ textAlign: 'center', padding: 80 }}>\n        <Spin size=\"large\" />\n      </div>\n    );\n  }\n\n  // Offline state\n  if (!online || !codeServerUrl) {\n    return (\n      <Result\n        status=\"error\"\n        title=\"Code Server Unavailable\"\n        subTitle=\"Code Server is not running or could not be reached. Check that the code-server container is started.\"\n        extra={\n          <Button type=\"primary\" onClick={fetchStatus}>\n            Retry\n          </Button>\n        }\n      />\n    );\n  }\n\n  // Iframe embed\n  return (\n    <iframe\n      src={codeServerUrl}\n      style={{\n        width: '100%',\n        height: 'calc(100vh - 64px)',\n        border: 'none',\n        display: 'block',\n      }}\n      title=\"Code Server\"\n    />\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/code-editor-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#local-component-state","title":"Local Component State","text":"
// Service online/offline state\nconst [online, setOnline] = useState<boolean | null>(null);\n\n// Code Server port configuration\nconst [codeServerPort, setCodeServerPort] = useState<number | null>(null);\n\n// Loading state\nconst [loading, setLoading] = useState(true);\n\n// Responsive breakpoint detection\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n
"},{"location":"v2/frontend/pages/admin/code-editor-page/#state-flow","title":"State Flow","text":"
  1. Component Mounts:
  2. fetchStatus() called
  3. Parallel API calls:
  4. Sets online and codeServerPort
  5. Constructs URL: //${hostname}:${port}

  6. URL Construction:

  7. Uses current hostname (from window.location.hostname)
  8. Appends Code Server port (default: 8888)
  9. Example: //localhost:8888 or //app.cmlite.org:8888
"},{"location":"v2/frontend/pages/admin/code-editor-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/docs/status - Check MkDocs and Code Server health
  2. GET /api/docs/config - Fetch Code Server port configuration
"},{"location":"v2/frontend/pages/admin/code-editor-page/#example-api-calls","title":"Example API Calls","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#1-fetch-service-status","title":"1. Fetch Service Status","text":"
const statusRes = await api.get<DocsStatus>('/docs/status');\nsetOnline(statusRes.data.codeServer.online);\n

Response Format:

{\n  \"mkdocs\": { \"online\": true },\n  \"codeServer\": { \"online\": true }\n}\n

"},{"location":"v2/frontend/pages/admin/code-editor-page/#2-fetch-config","title":"2. Fetch Config","text":"
const configRes = await api.get<DocsConfig>('/docs/config');\nsetCodeServerPort(configRes.data.codeServerPort);\n

Response Format:

{\n  \"mkdocsPort\": 4003,\n  \"codeServerPort\": 8888\n}\n

"},{"location":"v2/frontend/pages/admin/code-editor-page/#3-build-url","title":"3. Build URL","text":"
const codeServerUrl = codeServerPort\n  ? `//${window.location.hostname}:${codeServerPort}`\n  : null;\n\n// Example results:\n// localhost \u2192 \"//localhost:8888\"\n// app.cmlite.org \u2192 \"//app.cmlite.org:8888\"\n
"},{"location":"v2/frontend/pages/admin/code-editor-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#1-parallel-api-requests","title":"1. Parallel API Requests","text":"
const [statusRes, configRes] = await Promise.all([\n  api.get<DocsStatus>('/docs/status'),\n  api.get<DocsConfig>('/docs/config'),\n]);\n

Benefit: Reduces total loading time by ~50%.

"},{"location":"v2/frontend/pages/admin/code-editor-page/#2-early-mobile-detection","title":"2. Early Mobile Detection","text":"
if (isMobile) {\n  return <Result />;  // No API calls, no iframe\n}\n

Benefit: Saves bandwidth and API requests on mobile devices.

"},{"location":"v2/frontend/pages/admin/code-editor-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#mobile-warning","title":"Mobile Warning","text":"
if (isMobile) {\n  return (\n    <Result\n      status=\"info\"\n      title=\"Desktop Required\"\n      subTitle=\"The code editor requires a desktop browser with a larger screen.\"\n      icon={<CodeOutlined style={{ fontSize: 48 }} />}\n    />\n  );\n}\n

Why Mobile Warning? - VS Code UI requires large screen (file explorer + editor + terminal) - Keyboard shortcuts essential (Ctrl+P, Ctrl+S, etc.) - Terminal commands difficult on mobile keyboards - Better UX to SSH into server directly from mobile

"},{"location":"v2/frontend/pages/admin/code-editor-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#problem-service-shows-offline","title":"Problem: Service Shows \"Offline\"","text":"

Solutions:

  1. Check Docker container:

    docker compose ps code-server\n

  2. Check logs:

    docker compose logs code-server\n

  3. Test direct access:

  4. Open http://localhost:8888 in browser

  5. Restart service:

    docker compose restart code-server\n

"},{"location":"v2/frontend/pages/admin/code-editor-page/#problem-iframe-not-loading","title":"Problem: Iframe Not Loading","text":"

Solutions:

  1. Check password:
  2. Code Server requires password authentication
  3. Check CODE_SERVER_PASSWORD env var in .env

  4. Check CSP headers:

  5. Open DevTools Console
  6. Look for Content Security Policy errors

  7. Try \"Open in New Tab\":

  8. Click button to test service directly
"},{"location":"v2/frontend/pages/admin/code-editor-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/","title":"CutExportPage","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#overview","title":"Overview","text":"

File: admin/src/pages/CutExportPage.tsx

Route: /app/map/cuts/:id/export

Role Requirements: Any authenticated admin user (uses authenticate middleware + admin role check)

Purpose: Generates a printable location report for a specific cut (geographic boundary). The report includes cut statistics, support level breakdown, and a detailed table of all addresses within the cut. Campaign organizers use this report for planning canvassing efforts, analyzing voter support distribution, and exporting contact lists for targeted outreach.

Key Features: - Printable cut location report optimized for landscape printing - Cut metadata (name, category, assigned person) - Statistics cards (total addresses, support levels, signs, contact info) - Paginated address table with support levels, contact info, and notes - Color-coded support level tags - Print-optimized styling with CSS @media print rules - Landscape orientation for wide table layout

Layout: Full AppLayout with \"Back to Cuts\" and \"Print\" buttons in header

Dependencies: - Ant Design v5 (Button, Typography, Spin, Space, Table, Tag, Row, Col, Card, Statistic, message) - react-router-dom (useParams, useNavigate, useOutletContext) - dayjs for date formatting

"},{"location":"v2/frontend/pages/admin/cut-export-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#1-cut-metadata-header","title":"1. Cut Metadata Header","text":"

Displayed Information: - Cut Name: Title of the cut (e.g., \"Downtown District - Block 5\") - Cut Category: Visual tag (e.g., \"Priority\", \"Target\", \"Base\") - Assigned To: Person responsible for canvassing this cut - Generation Timestamp: Date and time report was generated

Purpose: Provides context for the location report

"},{"location":"v2/frontend/pages/admin/cut-export-page/#2-statistics-grid","title":"2. Statistics Grid","text":"

9 Statistics Cards:

  1. Total: Total number of addresses in cut
  2. Strong: Count of LEVEL_1 support (strong supporters)
  3. Likely: Count of LEVEL_2 support (likely supporters)
  4. Unsure: Count of LEVEL_3 support (undecided voters)
  5. Oppose: Count of LEVEL_4 support (opponents)
  6. None: Count of addresses with no support level assigned
  7. Signs: Count of addresses requesting lawn signs
  8. Email: Count of addresses with email addresses
  9. Phone: Count of addresses with phone numbers

Color-Coded Values: - Strong Support: Green (#52c41a) - Likely Support: Cyan (#13c2c2) - Unsure: Orange (#faad14) - Oppose: Red (#ff4d4f) - None/Other: Default gray

Layout: Responsive grid (9 cards, 3 per row on desktop, 2 per row on mobile)

"},{"location":"v2/frontend/pages/admin/cut-export-page/#3-address-table","title":"3. Address Table","text":"

8 Columns:

  1. Name: First name + last name (combined)
  2. Address: Building street address + unit number (if multi-unit)
  3. Support: Support level tag (Strong/Likely/Unsure/Oppose/None)
  4. Phone: Phone number or \"--\"
  5. Email: Email address or \"--\"
  6. Sign: Sign interest (\"Yes\" with size, or \"No\")
  7. Notes: Additional notes (ellipsis if long)

Table Features: - Bordered table for clear gridlines - Small size (compact rows) - No pagination (print all addresses on multiple pages if needed) - Sortable columns (default Ant Design behavior)

"},{"location":"v2/frontend/pages/admin/cut-export-page/#4-footer","title":"4. Footer","text":"

Footer Text: \"Generated by Changemaker Lite \u2014 {timestamp}\"

Purpose: Attribution and timestamp for report archiving

"},{"location":"v2/frontend/pages/admin/cut-export-page/#5-print-optimization","title":"5. Print Optimization","text":"

CSS @media print Rules: - Hides everything except .cut-export-print container - Positions report at absolute top-left with fixed position - Uses landscape orientation (@page { size: letter landscape; }) - Reduces font size to 9-10px for compact printing - Optimizes table padding and borders for clarity - Forces exact color printing with print-color-adjust: exact

Print Trigger: \"Print\" button in page header (calls window.print())

"},{"location":"v2/frontend/pages/admin/cut-export-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#exporting-a-cut-report","title":"Exporting a Cut Report","text":"
  1. Navigate to Cuts:
  2. Click \"Map\" \u2192 \"Cuts\" in sidebar
  3. Cuts table loads

  4. Select Cut:

  5. Find cut to export in table
  6. Click \"Export\" action button (or similar)
  7. Route navigates to /app/map/cuts/:id/export

  8. Review Report Preview:

  9. Page loads with cut metadata header
  10. Statistics cards show support level distribution
  11. Address table lists all locations in cut

  12. Print Report:

  13. Click \"Print\" button in page header
  14. OR press Ctrl+P (Windows/Linux) or Cmd+P (Mac)
  15. Browser print dialog opens

  16. Configure Print Settings:

  17. Orientation: Landscape (automatically set by CSS)
  18. Paper Size: Letter (8.5\" \u00d7 11\")
  19. Margins: Minimal (0.25\")
  20. Background graphics: ON (to print color tags and borders)

  21. Print or Save PDF:

  22. Click \"Print\" to send to printer
  23. OR select \"Save as PDF\" to create digital copy
  24. Report saved/printed for field use
"},{"location":"v2/frontend/pages/admin/cut-export-page/#analyzing-cut-statistics","title":"Analyzing Cut Statistics","text":"
  1. Review Statistics Cards:
  2. Total: Understand cut size (e.g., 150 addresses)
  3. Strong + Likely: Identify supporter base (e.g., 80 strong + 30 likely = 110 supporters)
  4. Unsure: Target for persuasion (e.g., 40 undecided)
  5. Oppose: Avoid during canvassing (e.g., 10 opponents)
  6. None: Not yet contacted (e.g., 10 addresses)

  7. Calculate Support Percentage:

  8. Strong Support % = (Strong / Total) \u00d7 100
  9. Example: (80 / 150) \u00d7 100 = 53.3% strong support

  10. Assess Contact Coverage:

  11. Email: Contact via email campaigns (e.g., 90 emails = 60% coverage)
  12. Phone: Contact via phone banking (e.g., 100 phones = 67% coverage)
  13. Signs: Distribute lawn signs (e.g., 50 sign requests)

  14. Plan Canvassing Strategy:

  15. High support areas: Focus on turnout (ensure supporters vote)
  16. High unsure areas: Focus on persuasion (door-to-door conversations)
  17. High oppose areas: Skip or minimal contact (avoid antagonism)
"},{"location":"v2/frontend/pages/admin/cut-export-page/#using-report-for-canvassing","title":"Using Report for Canvassing","text":"
  1. Print Report Before Canvassing:
  2. Export cut report
  3. Print landscape orientation
  4. Bring printed report to field

  5. Review Addresses During Canvass:

  6. Check support level before knocking
  7. Note contact info (phone/email) for follow-up
  8. See sign requests (bring signs to those addresses)

  9. Update Notes During Canvass:

  10. Handwrite additional notes on printed report (e.g., \"Not home\", \"Call back after 6pm\")
  11. Mark addresses as \"Visited\" with checkmarks

  12. Data Entry After Canvass:

  13. Return to office with updated report
  14. Enter new data into LocationsPage or AddressPage
  15. Update support levels, contact info, notes
"},{"location":"v2/frontend/pages/admin/cut-export-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#main-component-structure","title":"Main Component Structure","text":"
export default function CutExportPage() {\n  const { id } = useParams<{ id: string }>();\n  const navigate = useNavigate();\n  const [cut, setCut] = useState<Cut | null>(null);\n  const [addresses, setAddresses] = useState<AddressWithLocation[]>([]);\n  const [stats, setStats] = useState<CutStatistics | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  // Load cut data on mount\n  useEffect(() => {\n    if (!id) return;\n    (async () => {\n      try {\n        // Parallel fetch: cut metadata, locations, statistics\n        const [cutRes, locsRes, statsRes] = await Promise.all([\n          api.get<Cut>(`/map/cuts/${id}`),\n          api.get<Location[]>(`/map/cuts/${id}/locations`),\n          api.get<CutStatistics>(`/map/cuts/${id}/statistics`),\n        ]);\n\n        setCut(cutRes.data);\n\n        // Flatten locations with their addresses\n        const flatAddresses: AddressWithLocation[] = [];\n        for (const loc of locsRes.data) {\n          if (loc.addresses && loc.addresses.length > 0) {\n            for (const addr of loc.addresses) {\n              flatAddresses.push({\n                ...addr,\n                locationAddress: loc.address, // Building street address\n              });\n            }\n          }\n        }\n        setAddresses(flatAddresses);\n\n        setStats(statsRes.data);\n      } catch {\n        message.error('Failed to load cut data');\n      } finally {\n        setLoading(false);\n      }\n    })();\n  }, [id]);\n\n  // Set page header with actions\n  const headerActions = useMemo(() => (\n    <Space>\n      <Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/app/map/cuts')}>\n        Back to Cuts\n      </Button>\n      <Button type=\"primary\" icon={<PrinterOutlined />} onClick={() => window.print()}>\n        Print\n      </Button>\n    </Space>\n  ), [navigate]);\n\n  useEffect(() => {\n    setPageHeader({ title: cut?.name || 'Cut Export', actions: headerActions });\n    return () => setPageHeader(null);\n  }, [setPageHeader, headerActions, cut?.name]);\n\n  if (loading) {\n    return <Spin size=\"large\" />;\n  }\n\n  if (!cut) {\n    return <Text type=\"danger\">Cut not found</Text>;\n  }\n\n  const now = dayjs().format('YYYY-MM-DD HH:mm');\n\n  return (\n    <>\n      <style>{/* Print CSS rules */}</style>\n\n      <div className=\"cut-export-print\">\n        {/* Report header */}\n        <Row justify=\"space-between\" align=\"middle\">\n          <Col>\n            <Title level={4}>{cut.name}</Title>\n            <Space>\n              <Tag color={CUT_CATEGORY_COLORS[cut.category]}>\n                {CUT_CATEGORY_LABELS[cut.category]}\n              </Tag>\n              {cut.assignedTo && <Text type=\"secondary\">Assigned to: {cut.assignedTo}</Text>}\n            </Space>\n          </Col>\n          <Col>\n            <Text type=\"secondary\">Generated: {now}</Text>\n          </Col>\n        </Row>\n\n        {/* Stats grid */}\n        <Row gutter={[12, 12]}>\n          <Col xs={8} sm={4}><Card size=\"small\"><Statistic title=\"Total\" value={stats.total} /></Card></Col>\n          <Col xs={8} sm={4}><Card size=\"small\"><Statistic title=\"Strong\" value={stats.byLevel.LEVEL_1} valueStyle={{ color: '#52c41a' }} /></Card></Col>\n          {/* ... more stats cards ... */}\n        </Row>\n\n        {/* Address table */}\n        <Table\n          columns={columns}\n          dataSource={addresses}\n          rowKey=\"id\"\n          pagination={false}\n          size=\"small\"\n          bordered\n        />\n\n        {/* Footer */}\n        <div style={{ marginTop: 16, textAlign: 'center' }}>\n          <Text type=\"secondary\">Generated by Changemaker Lite \u2014 {now}</Text>\n        </div>\n      </div>\n    </>\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/cut-export-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  1. Button - \"Back to Cuts\" and \"Print\" buttons
  2. Typography.Title - Cut name heading
  3. Typography.Text - Labels, timestamps, footer
  4. Spin - Loading indicator during data fetch
  5. Space - Button grouping, tag grouping
  6. Table - Address data grid
  7. Tag - Cut category, support levels
  8. Row / Col - Statistics grid layout
  9. Card - Statistics card containers
  10. Statistic - Numerical statistics display
  11. message - Toast notifications for errors
"},{"location":"v2/frontend/pages/admin/cut-export-page/#table-column-definition","title":"Table Column Definition","text":"
const columns: ColumnsType<AddressWithLocation> = [\n  {\n    title: 'Name',\n    key: 'name',\n    render: (_: unknown, record: AddressWithLocation) =>\n      [record.firstName, record.lastName].filter(Boolean).join(' ') || '--',\n  },\n  {\n    title: 'Address',\n    key: 'address',\n    render: (_: unknown, record: AddressWithLocation) =>\n      [record.locationAddress, record.unitNumber && `#${record.unitNumber}`].filter(Boolean).join(' ') || '--',\n  },\n  {\n    title: 'Support',\n    dataIndex: 'supportLevel',\n    key: 'supportLevel',\n    width: 120,\n    render: (level: SupportLevel | null) =>\n      level ? (\n        <Tag color={SUPPORT_LEVEL_COLORS[level]}>{SUPPORT_LEVEL_LABELS[level]}</Tag>\n      ) : (\n        <Tag>None</Tag>\n      ),\n  },\n  {\n    title: 'Phone',\n    dataIndex: 'phone',\n    key: 'phone',\n    width: 120,\n    render: (val: string | null) => val || '--',\n  },\n  {\n    title: 'Email',\n    dataIndex: 'email',\n    key: 'email',\n    width: 180,\n    render: (val: string | null) => val || '--',\n  },\n  {\n    title: 'Sign',\n    key: 'sign',\n    width: 80,\n    render: (_: unknown, record: AddressWithLocation) =>\n      record.sign\n        ? `Yes${record.signSize ? ` (${record.signSize})` : ''}`\n        : 'No',\n  },\n  {\n    title: 'Notes',\n    dataIndex: 'notes',\n    key: 'notes',\n    width: 150,\n    ellipsis: true,\n    render: (val: string | null) => val || '--',\n  },\n];\n
"},{"location":"v2/frontend/pages/admin/cut-export-page/#print-css-styling","title":"Print CSS Styling","text":"
<style>{`\n  @media print {\n    /* Hide everything except report */\n    body * { visibility: hidden !important; }\n    .cut-export-print, .cut-export-print * { visibility: visible !important; }\n\n    /* Position report at top-left */\n    .cut-export-print {\n      position: fixed !important;\n      left: 0 !important;\n      top: 0 !important;\n      width: 100% !important;\n      padding: 0.4in !important;\n      background: white !important;\n      color: black !important;\n      font-size: 10px !important;\n    }\n\n    /* Compact table styling */\n    .cut-export-print .ant-table { font-size: 9px !important; }\n    .cut-export-print .ant-table-thead > tr > th {\n      background: #f0f0f0 !important;\n      print-color-adjust: exact;\n      -webkit-print-color-adjust: exact;\n      padding: 4px 6px !important;\n    }\n    .cut-export-print .ant-table-tbody > tr > td { padding: 3px 6px !important; }\n\n    /* Force exact color printing for tags */\n    .cut-export-print .ant-tag {\n      print-color-adjust: exact;\n      -webkit-print-color-adjust: exact;\n    }\n\n    /* Remove card shadows for print */\n    .cut-export-print .ant-card { box-shadow: none !important; border: 1px solid #ddd !important; }\n\n    /* Landscape orientation */\n    @page { size: letter landscape; margin: 0.25in; }\n  }\n`}</style>\n

Key Print Rules: - visibility: hidden !important on all elements except .cut-export-print - Fixed positioning at top-left (0, 0) with 0.4in padding - 9-10px font sizes for compact printing - print-color-adjust: exact forces exact color printing (tags, statistics) - Landscape orientation via @page { size: letter landscape; } - Minimal margins (0.25in) to maximize table width

"},{"location":"v2/frontend/pages/admin/cut-export-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#local-component-state-usestate","title":"Local Component State (useState)","text":"

No Zustand stores used - All state managed locally with React hooks.

// Cut metadata state\nconst [cut, setCut] = useState<Cut | null>(null);\n\n// Flattened addresses state (Location + Address combined)\nconst [addresses, setAddresses] = useState<AddressWithLocation[]>([]);\n\n// Cut statistics state\nconst [stats, setStats] = useState<CutStatistics | null>(null);\n\n// Loading state\nconst [loading, setLoading] = useState(true);\n
"},{"location":"v2/frontend/pages/admin/cut-export-page/#state-flow","title":"State Flow","text":"
  1. Component Mounts:
  2. Extracts id from URL params (:id in /app/map/cuts/:id/export)
  3. Calls 3 parallel API requests:
  4. Sets cut, addresses, stats states
  5. Sets loading to false

  6. Address Flattening:

  7. API returns locations with nested addresses array
  8. Component flattens to single AddressWithLocation[] array:
    const flatAddresses: AddressWithLocation[] = [];\nfor (const loc of locations) {\n  if (loc.addresses && loc.addresses.length > 0) {\n    for (const addr of loc.addresses) {\n      flatAddresses.push({\n        ...addr,\n        locationAddress: loc.address, // Add parent location address\n      });\n    }\n  }\n}\n
  9. Result: One row per address (not per location)

  10. Statistics Rendering:

  11. stats.total \u2192 Total card
  12. stats.byLevel.LEVEL_1 \u2192 Strong card
  13. stats.byLevel.LEVEL_2 \u2192 Likely card
  14. stats.byLevel.LEVEL_3 \u2192 Unsure card
  15. stats.byLevel.LEVEL_4 \u2192 Oppose card
  16. stats.byLevel.NONE \u2192 None card
  17. stats.withSign \u2192 Signs card
  18. Count emails/phones from addresses array

  19. User Clicks Print:

  20. window.print() called
  21. Browser opens print dialog
  22. Print CSS rules activate
  23. Report rendered in landscape layout
"},{"location":"v2/frontend/pages/admin/cut-export-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/map/cuts/:id - Fetch cut metadata (name, category, assignedTo)
  2. GET /api/map/cuts/:id/locations - Fetch all locations within cut (with nested addresses)
  3. GET /api/map/cuts/:id/statistics - Fetch aggregated cut statistics (support levels, signs)
"},{"location":"v2/frontend/pages/admin/cut-export-page/#api-client","title":"API Client","text":"
import { api } from '@/lib/api';\n\n// All requests use authenticated API client with automatic token refresh\n
"},{"location":"v2/frontend/pages/admin/cut-export-page/#example-api-calls","title":"Example API Calls","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#1-fetch-cut-metadata","title":"1. Fetch Cut Metadata","text":"
const cutRes = await api.get<Cut>(`/map/cuts/${id}`);\nsetCut(cutRes.data);\n

Response Format:

{\n  \"id\": 5,\n  \"name\": \"Downtown District - Block 5\",\n  \"category\": \"PRIORITY\",\n  \"assignedTo\": \"Jane Smith\",\n  \"geometry\": {...},\n  \"createdAt\": \"2025-01-15T10:00:00Z\",\n  \"updatedAt\": \"2025-02-11T12:00:00Z\"\n}\n

"},{"location":"v2/frontend/pages/admin/cut-export-page/#2-fetch-locations-with-addresses","title":"2. Fetch Locations with Addresses","text":"
const locsRes = await api.get<Location[]>(`/map/cuts/${id}/locations`);\nconst locations = locsRes.data;\n

Response Format:

[\n  {\n    \"id\": 101,\n    \"address\": \"123 Main St\",\n    \"lat\": 45.5017,\n    \"lng\": -73.5673,\n    \"addresses\": [\n      {\n        \"id\": 1001,\n        \"firstName\": \"John\",\n        \"lastName\": \"Doe\",\n        \"unitNumber\": \"101\",\n        \"supportLevel\": \"LEVEL_1\",\n        \"phone\": \"555-1234\",\n        \"email\": \"john@example.com\",\n        \"sign\": true,\n        \"signSize\": \"18x24\",\n        \"notes\": \"Strong supporter, wants yard sign\"\n      },\n      {\n        \"id\": 1002,\n        \"firstName\": \"Jane\",\n        \"lastName\": \"Smith\",\n        \"unitNumber\": \"102\",\n        \"supportLevel\": \"LEVEL_2\",\n        \"phone\": \"555-5678\",\n        \"email\": \"jane@example.com\",\n        \"sign\": false,\n        \"notes\": \"Likely supporter, call after 6pm\"\n      }\n    ]\n  },\n  {\n    \"id\": 102,\n    \"address\": \"125 Main St\",\n    \"lat\": 45.5018,\n    \"lng\": -73.5674,\n    \"addresses\": [\n      {\n        \"id\": 1003,\n        \"firstName\": \"Bob\",\n        \"lastName\": \"Johnson\",\n        \"unitNumber\": null,\n        \"supportLevel\": \"LEVEL_3\",\n        \"phone\": null,\n        \"email\": null,\n        \"sign\": false,\n        \"notes\": \"Undecided, needs more info\"\n      }\n    ]\n  }\n]\n

"},{"location":"v2/frontend/pages/admin/cut-export-page/#3-fetch-cut-statistics","title":"3. Fetch Cut Statistics","text":"
const statsRes = await api.get<CutStatistics>(`/map/cuts/${id}/statistics`);\nsetStats(statsRes.data);\n

Response Format:

{\n  \"total\": 150,\n  \"byLevel\": {\n    \"LEVEL_1\": 80,\n    \"LEVEL_2\": 30,\n    \"LEVEL_3\": 25,\n    \"LEVEL_4\": 10,\n    \"NONE\": 5\n  },\n  \"withSign\": 50,\n  \"withPhone\": 100,\n  \"withEmail\": 90\n}\n

"},{"location":"v2/frontend/pages/admin/cut-export-page/#4-parallel-api-calls-pattern","title":"4. Parallel API Calls Pattern","text":"
useEffect(() => {\n  if (!id) return;\n  (async () => {\n    try {\n      // Fetch all 3 endpoints in parallel\n      const [cutRes, locsRes, statsRes] = await Promise.all([\n        api.get<Cut>(`/map/cuts/${id}`),\n        api.get<Location[]>(`/map/cuts/${id}/locations`),\n        api.get<CutStatistics>(`/map/cuts/${id}/statistics`),\n      ]);\n\n      setCut(cutRes.data);\n\n      // Flatten locations with addresses\n      const flatAddresses: AddressWithLocation[] = [];\n      for (const loc of locsRes.data) {\n        if (loc.addresses && loc.addresses.length > 0) {\n          for (const addr of loc.addresses) {\n            flatAddresses.push({\n              ...addr,\n              locationAddress: loc.address,\n            });\n          }\n        }\n      }\n      setAddresses(flatAddresses);\n\n      setStats(statsRes.data);\n    } catch {\n      message.error('Failed to load cut data');\n    } finally {\n      setLoading(false);\n    }\n  })();\n}, [id]);\n

Benefit: Parallel requests reduce total loading time (3 requests in ~200ms instead of ~600ms sequential).

"},{"location":"v2/frontend/pages/admin/cut-export-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#complete-address-flattening-logic","title":"Complete Address Flattening Logic","text":"
interface AddressWithLocation extends Address {\n  locationAddress: string; // Building street address from parent Location\n}\n\n// Flatten locations with their addresses\nconst flatAddresses: AddressWithLocation[] = [];\nfor (const loc of locations) {\n  if (loc.addresses && loc.addresses.length > 0) {\n    for (const addr of loc.addresses) {\n      flatAddresses.push({\n        ...addr,\n        locationAddress: loc.address, // Add parent location address\n      });\n    }\n  }\n}\n\nsetAddresses(flatAddresses);\n

Result: - Input: 100 locations with 2-10 addresses each - Output: 500 flat addresses (one row per address in table)

"},{"location":"v2/frontend/pages/admin/cut-export-page/#statistics-grid-rendering","title":"Statistics Grid Rendering","text":"
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>\n  <Col xs={8} sm={4}>\n    <Card size=\"small\">\n      <Statistic title=\"Total\" value={stats.total} />\n    </Card>\n  </Col>\n  <Col xs={8} sm={4}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Strong\"\n        value={stats.byLevel.LEVEL_1 || 0}\n        valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_1 }}\n      />\n    </Card>\n  </Col>\n  <Col xs={8} sm={4}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Likely\"\n        value={stats.byLevel.LEVEL_2 || 0}\n        valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_2 }}\n      />\n    </Card>\n  </Col>\n  <Col xs={8} sm={4}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Unsure\"\n        value={stats.byLevel.LEVEL_3 || 0}\n        valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_3 }}\n      />\n    </Card>\n  </Col>\n  <Col xs={8} sm={4}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Oppose\"\n        value={stats.byLevel.LEVEL_4 || 0}\n        valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_4 }}\n      />\n    </Card>\n  </Col>\n  <Col xs={8} sm={4}>\n    <Card size=\"small\">\n      <Statistic title=\"None\" value={stats.byLevel.NONE || 0} />\n    </Card>\n  </Col>\n  <Col xs={8} sm={4}>\n    <Card size=\"small\">\n      <Statistic title=\"Signs\" value={stats.withSign} />\n    </Card>\n  </Col>\n  <Col xs={8} sm={4}>\n    <Card size=\"small\">\n      <Statistic title=\"Email\" value={addresses.filter(a => a.email).length} />\n    </Card>\n  </Col>\n  <Col xs={8} sm={4}>\n    <Card size=\"small\">\n      <Statistic title=\"Phone\" value={addresses.filter(a => a.phone).length} />\n    </Card>\n  </Col>\n</Row>\n
"},{"location":"v2/frontend/pages/admin/cut-export-page/#support-level-tag-rendering","title":"Support Level Tag Rendering","text":"
{\n  title: 'Support',\n  dataIndex: 'supportLevel',\n  key: 'supportLevel',\n  width: 120,\n  render: (level: SupportLevel | null) =>\n    level ? (\n      <Tag color={SUPPORT_LEVEL_COLORS[level]}>\n        {SUPPORT_LEVEL_LABELS[level]}\n      </Tag>\n    ) : (\n      <Tag>None</Tag>\n    ),\n}\n\n// Constants (from types/api.ts)\nexport const SUPPORT_LEVEL_LABELS = {\n  LEVEL_1: 'Strong',\n  LEVEL_2: 'Likely',\n  LEVEL_3: 'Unsure',\n  LEVEL_4: 'Oppose',\n  NONE: 'None',\n};\n\nexport const SUPPORT_LEVEL_COLORS = {\n  LEVEL_1: 'green',\n  LEVEL_2: 'cyan',\n  LEVEL_3: 'orange',\n  LEVEL_4: 'red',\n  NONE: 'default',\n};\n
"},{"location":"v2/frontend/pages/admin/cut-export-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#1-parallel-api-requests","title":"1. Parallel API Requests","text":"

Three API calls made in parallel with Promise.all():

const [cutRes, locsRes, statsRes] = await Promise.all([\n  api.get<Cut>(`/map/cuts/${id}`),\n  api.get<Location[]>(`/map/cuts/${id}/locations`),\n  api.get<CutStatistics>(`/map/cuts/${id}/statistics`),\n]);\n

Benefit: Total loading time ~200ms (slowest request) instead of ~600ms (sum of all requests).

"},{"location":"v2/frontend/pages/admin/cut-export-page/#2-address-flattening-onm","title":"2. Address Flattening (O(n*m))","text":"

Flattening addresses is O(n\u00d7m) where n = locations, m = addresses per location:

for (const loc of locations) {        // O(n)\n  for (const addr of loc.addresses) {  // O(m)\n    flatAddresses.push({...addr, locationAddress: loc.address});\n  }\n}\n

Complexity: O(n\u00d7m), typically O(100\u00d75) = O(500) operations

Benefit: Simple nested loop, fast for typical cut sizes (< 1000 addresses).

"},{"location":"v2/frontend/pages/admin/cut-export-page/#3-no-pagination-print-all","title":"3. No Pagination (Print All)","text":"

Table has pagination={false}:

<Table\n  dataSource={addresses}\n  pagination={false}  // Print all addresses\n/>\n

Trade-off: - Benefit: All addresses visible in one print job (no manual page-turning) - Cost: Large cuts (> 500 addresses) may slow page load slightly

Rationale: Printable reports typically exported for offline use, so full dataset preferred over pagination.

"},{"location":"v2/frontend/pages/admin/cut-export-page/#4-usememo-for-header-actions","title":"4. useMemo for Header Actions","text":"

Header actions memoized with useMemo to prevent re-renders:

const headerActions = useMemo(() => (\n  <Space>\n    <Button onClick={() => navigate('/app/map/cuts')}>Back</Button>\n    <Button onClick={() => window.print()}>Print</Button>\n  </Space>\n), [navigate]);\n

Benefit: Header actions only recreated if navigate changes (never changes), preventing unnecessary re-renders.

"},{"location":"v2/frontend/pages/admin/cut-export-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#desktop-first-layout","title":"Desktop-First Layout","text":"

Report optimized for desktop printing, not mobile viewing: - Landscape orientation (@page { size: letter landscape; }) - 9 statistics cards in responsive grid (3 per row on desktop, 2 per row on mobile) - Wide table with 8 columns (requires landscape for clarity)

"},{"location":"v2/frontend/pages/admin/cut-export-page/#responsive-statistics-grid","title":"Responsive Statistics Grid","text":"
<Row gutter={[12, 12]}>\n  <Col xs={8} sm={4}>  {/* 3 per row mobile, 6 per row desktop */}\n    <Card size=\"small\">\n      <Statistic title=\"Total\" value={stats.total} />\n    </Card>\n  </Col>\n  {/* ... 8 more cards ... */}\n</Row>\n

Breakpoints: - xs={8}: 3 cards per row on mobile (8+8+8 = 24 columns) - sm={4}: 6 cards per row on tablet (4\u00d76 = 24 columns) - md+: 6-9 cards per row on desktop (depends on screen width)

"},{"location":"v2/frontend/pages/admin/cut-export-page/#print-layout-landscape","title":"Print Layout (Landscape)","text":"
@page {\n  size: letter landscape;\n  margin: 0.25in;\n}\n

Landscape Orientation: - Paper: 11\" wide \u00d7 8.5\" tall (instead of 8.5\" \u00d7 11\") - Allows 8-column table to fit without horizontal scroll - Critical for readability of address table

"},{"location":"v2/frontend/pages/admin/cut-export-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#print-only-page","title":"Print-Only Page","text":"

Cut export report is primarily for printing, not interactive use. Accessibility considerations minimal:

  1. Semantic HTML:
  2. <table> for address grid
  3. <th> for column headers
  4. <td> for data cells
  5. Proper heading hierarchy (<h4> for cut name)

  6. Keyboard Navigation:

  7. \"Back to Cuts\" button accessible via Tab + Enter
  8. \"Print\" button accessible via Tab + Enter

  9. Screen Reader Support:

  10. Table headers announced for each column
  11. Statistics titles read before values
  12. Tag labels announced (e.g., \"Strong Support\", \"Priority Cut\")

  13. Color Contrast:

  14. Support level tags meet WCAG AA standards
  15. Statistics value colors have sufficient contrast on white background

Note: Once printed, report relies on visual layout (table, colors, spacing) for interpretation.

"},{"location":"v2/frontend/pages/admin/cut-export-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#problem-report-shows-cut-not-found","title":"Problem: Report Shows \"Cut Not Found\"","text":"

Symptoms: - Navigate to /app/map/cuts/:id/export - Page shows error: \"Cut not found\" - No data displayed

Causes: 1. Invalid cut ID in URL (cut doesn't exist) 2. API returned 404 for cut 3. Cut deleted after URL generated

Solutions:

  1. Verify cut ID:
  2. Check URL bar: /app/map/cuts/5/export
  3. Note the ID number (5)
  4. Navigate to \"Map\" \u2192 \"Cuts\"
  5. Verify cut with ID 5 exists in table

  6. Check API response:

  7. Open browser DevTools (F12)
  8. Go to Network tab
  9. Look for GET /api/map/cuts/5 request
  10. Check response:

  11. Navigate from Cuts page:

  12. Instead of typing URL manually, click \"Export\" button from CutsPage
  13. This ensures valid cut ID used
"},{"location":"v2/frontend/pages/admin/cut-export-page/#problem-address-table-is-empty","title":"Problem: Address Table is Empty","text":"

Symptoms: - Cut metadata loads correctly (name, category, assigned person) - Statistics cards show zeros - Address table has no rows

Causes: 1. Cut has no locations assigned (empty polygon or no location assignment) 2. Locations have no addresses (only building locations, no unit/contact data) 3. API error fetching locations

Solutions:

  1. Check cut has locations:
  2. Navigate to \"Map\" \u2192 \"Cuts\"
  3. Click on cut name to view details
  4. Check \"Locations\" count in details modal
  5. If 0 locations, cut is empty (no addresses to export)

  6. Assign locations to cut:

  7. Navigate to \"Map\" \u2192 \"Locations\"
  8. Draw cut polygon on map
  9. Use point-in-polygon to assign locations
  10. Re-export cut

  11. Check locations have addresses:

  12. Navigate to \"Map\" \u2192 \"Locations\"
  13. Click on location in cut
  14. Check \"Addresses\" tab in location details
  15. If no addresses, add address records via CSV import or manual entry

  16. Check API response:

  17. Open browser DevTools (F12)
  18. Go to Network tab
  19. Look for GET /api/map/cuts/:id/locations request
  20. Check response:
"},{"location":"v2/frontend/pages/admin/cut-export-page/#problem-statistics-dont-match-address-table","title":"Problem: Statistics Don't Match Address Table","text":"

Symptoms: - Statistics cards show different counts than visible in address table - Example: \"Strong\" card shows 80, but table has 60 \"Strong\" tags

Causes: 1. Statistics API aggregates all addresses (including those without contact info) 2. Table filters out addresses with missing data 3. Race condition between API calls

Solutions:

  1. Verify statistics API:
  2. Statistics endpoint (/api/map/cuts/:id/statistics) counts ALL addresses
  3. Table may filter addresses (e.g., only show addresses with names)
  4. This is expected behavior

  5. Check table filters:

  6. No default filters applied in CutExportPage
  7. If custom filters added, they may hide addresses from table

  8. Refresh page:

  9. Hard refresh (Ctrl+Shift+R or Cmd+Shift+R)
  10. Clears cached data and re-fetches from API

  11. Check API responses match:

  12. Open browser DevTools (F12)
  13. Go to Network tab
  14. Compare responses:
"},{"location":"v2/frontend/pages/admin/cut-export-page/#problem-print-preview-is-blank","title":"Problem: Print Preview is Blank","text":"

Symptoms: - Click \"Print\" button - Print preview shows blank page - No content visible

Causes: 1. Print CSS not applying 2. Browser print settings incorrect 3. Content outside printable area

Solutions:

  1. Check print CSS:
  2. View page source (Ctrl+U or Cmd+U)
  3. Verify <style> tag with @media print rules exists
  4. If missing, print CSS not loaded

  5. Enable background graphics:

  6. In print dialog, check \"Background graphics\" option
  7. This ensures table borders and colors print

  8. Try different browser:

  9. Chrome, Firefox, and Edge have different print engines
  10. If one fails, try another

  11. Check browser console:

  12. Open DevTools (F12)
  13. Go to Console tab
  14. Look for CSS errors (e.g., invalid print rules)

  15. Use Ctrl+P instead of button:

  16. Press Ctrl+P (Windows/Linux) or Cmd+P (Mac)
  17. This bypasses custom print button and uses browser default
"},{"location":"v2/frontend/pages/admin/cut-export-page/#problem-table-columns-cut-off-when-printed","title":"Problem: Table Columns Cut Off When Printed","text":"

Symptoms: - Print preview shows table, but rightmost columns missing - Horizontal scrollbar visible in print preview

Causes: 1. Portrait orientation used instead of landscape 2. Table too wide for paper 3. Print scaling set to \"Fit to page\" (shrinks content)

Solutions:

  1. Verify landscape orientation:
  2. In print dialog, check \"Orientation: Landscape\"
  3. Landscape gives 11\" width instead of 8.5\"
  4. Critical for 8-column table

  5. Check print scaling:

  6. In print dialog, set scale to \"100%\" (not \"Fit to page\")
  7. \"Fit to page\" shrinks content, making text too small

  8. Reduce font sizes:

  9. If table still too wide, edit print CSS:

    @media print {\n  .cut-export-print { font-size: 8px !important; } /* Was 10px */\n  .cut-export-print .ant-table { font-size: 7px !important; } /* Was 9px */\n}\n

  10. Remove less important columns:

  11. Temporarily hide \"Notes\" column (least critical for field use):
    const columns = [\n  // ... other columns ...\n  // Comment out Notes column\n  // { title: 'Notes', dataIndex: 'notes', ... },\n];\n
"},{"location":"v2/frontend/pages/admin/cut-export-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#backend-documentation","title":"Backend Documentation","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#frontend-documentation","title":"Frontend Documentation","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#feature-documentation","title":"Feature Documentation","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#api-documentation","title":"API Documentation","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#user-guides","title":"User Guides","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#deployment-documentation","title":"Deployment Documentation","text":""},{"location":"v2/frontend/pages/admin/cuts-page/","title":"CutsPage","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#overview","title":"Overview","text":"

The CutsPage provides administrative management of geographic polygon boundaries (\"cuts\") used to organize canvassing territories for volunteer door-knocking campaigns. It offers a dual-view interface: a table view for CRUD operations on cut metadata, and an interactive map view for drawing new polygons, editing existing boundaries, and visualizing all cuts simultaneously. The page integrates with the Location system and Shift system to enable territory-based volunteer assignments.

Route: /app/map/cuts Component: admin/src/pages/CutsPage.tsx (561 lines) Auth Required: Yes (SUPER_ADMIN or MAP_ADMIN role recommended) Layout: AppLayout Backend Module: api/src/modules/map/cuts/

"},{"location":"v2/frontend/pages/admin/cuts-page/#screenshot","title":"Screenshot","text":"

[Screenshot: CutsPage with large Segmented control at top showing \"Table\" (selected with TableOutlined icon) and \"Map\" (EnvironmentOutlined icon). Below in table view: search bar, \"Create Cut\" primary button, and \"Import GeoJSON\" secondary button aligned right. Table has columns: Name (sortable), Description, Color (colored circle preview), Location Count (number badge), Created At (date), Actions. Each row shows Edit, View Locations, Export GeoJSON, and Delete buttons. Pagination at bottom. When \"Map\" tab selected: full-screen Leaflet map with CutEditorMap component showing existing cuts as colored polygons, drawing controls in top-left corner, and \"Save Cut\" button when polygon drawing complete.]

"},{"location":"v2/frontend/pages/admin/cuts-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#creating-a-new-cut-table-view","title":"Creating a New Cut (Table View)","text":"
  1. Navigate to /app/map/cuts
  2. Ensure \"Table\" tab is selected (default)
  3. Click \"Create Cut\" button (top right)
  4. Modal appears: \"Create Cut\"
  5. Fill in fields:
  6. Name: (required) e.g., \"Downtown Core\"
  7. Description: (optional) e.g., \"High-density residential area with apartment buildings\"
  8. Color: (required) Click color picker to select polygon color (default: #3498db blue)
  9. Click \"Create\" button
  10. Success message: \"Cut created successfully\"
  11. Modal closes, table refreshes to show new cut (with 0 locations initially)
  12. New cut appears in table with selected color preview circle
"},{"location":"v2/frontend/pages/admin/cuts-page/#drawing-a-cut-polygon-map-view","title":"Drawing a Cut Polygon (Map View)","text":"
  1. Click \"Map\" tab in Segmented control
  2. Map view appears with CutEditorMap component
  3. Existing cuts render as colored polygon overlays
  4. Click \"Draw New Cut\" button (top-left map controls)
  5. Drawing mode activates:
  6. Cursor changes to crosshair
  7. Instructional text: \"Click to place vertices. Double-click or click first vertex to close polygon.\"
  8. Click map to place first vertex (blue circle marker appears)
  9. Click again to place second vertex (line drawn between vertices)
  10. Continue clicking to place vertices (polygon outline forms)
  11. Close polygon by:
  12. Double-clicking final vertex, OR
  13. Clicking first vertex again (close detection radius: 10 pixels)
  14. Polygon closes automatically, fills with semi-transparent color
  15. Modal appears: \"Save Cut\"
  16. Fill in fields:
  17. Click \"Save\" button
  18. Backend calculates locations within polygon (point-in-polygon algorithm)
  19. Success message: \"Cut created successfully with 47 locations\"
  20. Polygon remains on map, now saved to database
  21. Switch to \"Table\" tab to see new cut with location count: 47
"},{"location":"v2/frontend/pages/admin/cuts-page/#editing-a-cut","title":"Editing a Cut","text":"
  1. In Table view, locate cut to edit
  2. Click \"Edit\" button in Actions column
  3. Modal appears: \"Edit Cut\"
  4. Modify fields:
  5. Name: Update cut name
  6. Description: Update or add description
  7. Color: Change polygon color (affects map visualization)
  8. Click \"Save\" button
  9. Success message: \"Cut updated successfully\"
  10. Table refreshes to show updated values
  11. Color preview circle updates to new color

Note: Editing cut metadata does NOT modify polygon boundary. To change boundary, must delete cut and redraw.

"},{"location":"v2/frontend/pages/admin/cuts-page/#importing-cuts-from-geojson","title":"Importing Cuts from GeoJSON","text":"
  1. In Table view, click \"Import GeoJSON\" button (top right, next to Create Cut)
  2. File picker opens
  3. Select GeoJSON file from local filesystem (e.g., cuts-export-2026-01-15.geojson)
  4. File uploads to backend
  5. Backend parses GeoJSON:
  6. Validates FeatureCollection format
  7. Extracts polygons from features
  8. Creates Cut records with properties (name, description, color)
  9. Calculates locations within each polygon
  10. Success message: \"Imported 5 cuts with 234 total locations\"
  11. Table refreshes to show imported cuts
  12. Switch to Map view to see imported polygons

GeoJSON Format Expected:

{\n  \"type\": \"FeatureCollection\",\n  \"features\": [\n    {\n      \"type\": \"Feature\",\n      \"geometry\": {\n        \"type\": \"Polygon\",\n        \"coordinates\": [\n          [\n            [-75.69602, 45.42153],\n            [-75.69102, 45.42153],\n            [-75.69102, 45.41653],\n            [-75.69602, 45.41653],\n            [-75.69602, 45.42153]\n          ]\n        ]\n      },\n      \"properties\": {\n        \"name\": \"Downtown Core\",\n        \"description\": \"High-density residential area\",\n        \"color\": \"#3498db\"\n      }\n    }\n  ]\n}\n
"},{"location":"v2/frontend/pages/admin/cuts-page/#exporting-a-cut-to-geojson","title":"Exporting a Cut to GeoJSON","text":"
  1. In Table view, locate cut to export
  2. Click \"Export GeoJSON\" button in Actions column
  3. Backend generates GeoJSON with:
  4. Polygon geometry (coordinates array)
  5. Cut properties (name, description, color)
  6. Location count metadata
  7. Browser downloads file: cut-{name}-{id}.geojson
  8. File can be opened in GIS software (QGIS, ArcGIS) or re-imported later

Example Export:

{\n  \"type\": \"Feature\",\n  \"geometry\": {\n    \"type\": \"Polygon\",\n    \"coordinates\": [\n      [\n        [-75.69602, 45.42153],\n        [-75.69102, 45.42153],\n        [-75.69102, 45.41653],\n        [-75.69602, 45.41653],\n        [-75.69602, 45.42153]\n      ]\n    ]\n  },\n  \"properties\": {\n    \"id\": \"cut_abc123\",\n    \"name\": \"Downtown Core\",\n    \"description\": \"High-density residential area\",\n    \"color\": \"#3498db\",\n    \"locationCount\": 47,\n    \"createdAt\": \"2026-01-15T10:30:00.000Z\"\n  }\n}\n
"},{"location":"v2/frontend/pages/admin/cuts-page/#viewing-locations-in-a-cut","title":"Viewing Locations in a Cut","text":"
  1. In Table view, locate cut
  2. Note location count badge (e.g., \"47 locations\")
  3. Click \"View Locations\" button in Actions column
  4. Navigates to /app/map/locations?cutId={cutId}
  5. LocationsPage opens with cut filter pre-applied
  6. Table shows only locations within that cut's polygon
  7. Can edit locations, view on map, or export to CSV
"},{"location":"v2/frontend/pages/admin/cuts-page/#deleting-a-cut","title":"Deleting a Cut","text":"
  1. In Table view, locate cut to delete
  2. Click \"Delete\" button in Actions column (red text)
  3. Confirmation modal appears: \"Are you sure you want to delete the cut 'Downtown Core'? This will unassign all locations from this cut but will not delete the locations themselves.\"
  4. Click \"Delete\" to confirm (or \"Cancel\" to abort)
  5. Backend:
  6. Deletes Cut record from database
  7. Sets cutId = null on all Location records within polygon (unassigns)
  8. Deletes associated Shift records (shifts are cut-specific)
  9. Success message: \"Cut deleted successfully. 47 locations unassigned.\"
  10. Table refreshes, deleted cut removed
  11. Switch to Map view: polygon no longer visible
"},{"location":"v2/frontend/pages/admin/cuts-page/#searching-cuts","title":"Searching Cuts","text":"
  1. Locate search bar at top of Table view (below Segmented control)
  2. Start typing search query (e.g., \"Downtown\")
  3. Search automatically triggers after 300ms pause (debounce)
  4. Table filters to show matching cuts
  5. Matches on: cut name, description
  6. Clear search by clicking X icon or deleting text
"},{"location":"v2/frontend/pages/admin/cuts-page/#sorting-the-table","title":"Sorting the Table","text":"
  1. Identify sortable columns (Name, Location Count, Created At)
  2. Click Name column header to sort alphabetically (A\u2192Z)
  3. Click again to reverse sort (Z\u2192A)
  4. Click Location Count to sort by number of locations (ascending)
  5. Click again to reverse sort (descending, highest first)
  6. Click Created At to sort by creation date (newest first)
  7. Can combine with search filter (sorted results only)
"},{"location":"v2/frontend/pages/admin/cuts-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#ant-design-components-used","title":"Ant Design Components Used","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#map-components-custom","title":"Map Components (Custom)","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#segmented-tab-control","title":"Segmented Tab Control","text":"
<Segmented\n  value={activeTab}\n  onChange={(val) => setActiveTab(val as string)}\n  options={[\n    {\n      value: 'table',\n      label: 'Table',\n      icon: <TableOutlined />,\n    },\n    {\n      value: 'map',\n      label: 'Map',\n      icon: <EnvironmentOutlined />,\n    },\n  ]}\n  size=\"large\"\n  block\n  style={{ marginBottom: 16 }}\n/>\n

Segmented Control Features: - Large size: Prominent button-style tabs - Block layout: Full-width tabs (50% each) - Icons: Visual indicators (TableOutlined, EnvironmentOutlined) - Value state: Controlled component with activeTab state - Smooth transition: Instant view switching (no loading)

"},{"location":"v2/frontend/pages/admin/cuts-page/#table-structure","title":"Table Structure","text":"
const columns: ColumnsType<Cut> = [\n  {\n    title: 'Name',\n    dataIndex: 'name',\n    key: 'name',\n    sorter: (a, b) => a.name.localeCompare(b.name),\n    width: 200,\n  },\n  {\n    title: 'Description',\n    dataIndex: 'description',\n    key: 'description',\n    width: 300,\n    ellipsis: true,\n    render: (text: string | null) => text || <Text type=\"secondary\">\u2014</Text>,\n  },\n  {\n    title: 'Color',\n    dataIndex: 'color',\n    key: 'color',\n    width: 80,\n    render: (color: string) => (\n      <div\n        style={{\n          width: 24,\n          height: 24,\n          borderRadius: '50%',\n          backgroundColor: color,\n          border: '2px solid rgba(0,0,0,0.1)',\n        }}\n      />\n    ),\n  },\n  {\n    title: 'Location Count',\n    dataIndex: 'locationCount',\n    key: 'locationCount',\n    width: 150,\n    sorter: (a, b) => (a.locationCount || 0) - (b.locationCount || 0),\n    render: (count: number) => (\n      <Tag color={count > 0 ? 'blue' : 'default'}>{count} locations</Tag>\n    ),\n  },\n  {\n    title: 'Created At',\n    dataIndex: 'createdAt',\n    key: 'createdAt',\n    width: 180,\n    sorter: (a, b) => dayjs(a.createdAt).unix() - dayjs(b.createdAt).unix(),\n    render: (date: string) => dayjs(date).format('MMM D, YYYY h:mm A'),\n  },\n  {\n    title: 'Actions',\n    key: 'actions',\n    width: 300,\n    fixed: 'right',\n    render: (_: unknown, record: Cut) => (\n      <Space size=\"small\" wrap>\n        <Button size=\"small\" type=\"link\" icon={<EditOutlined />} onClick={() => handleEdit(record)}>\n          Edit\n        </Button>\n        <Button\n          size=\"small\"\n          type=\"link\"\n          icon={<EnvironmentOutlined />}\n          onClick={() => handleViewLocations(record)}\n        >\n          View Locations\n        </Button>\n        <Button\n          size=\"small\"\n          type=\"link\"\n          icon={<DownloadOutlined />}\n          onClick={() => handleExportGeoJSON(record)}\n        >\n          Export GeoJSON\n        </Button>\n        <Button\n          size=\"small\"\n          type=\"link\"\n          danger\n          icon={<DeleteOutlined />}\n          onClick={() => handleDeleteConfirm(record)}\n        >\n          Delete\n        </Button>\n      </Space>\n    ),\n  },\n];\n

Column Features: - Name: Primary identifier, sortable, 200px width - Description: Optional text, ellipsis for overflow, nullable (shows \"\u2014\" if null) - Color: Visual circle preview (24px diameter, rounded, bordered), 80px width - Location Count: Number of locations within polygon, color-coded tag (blue if > 0, gray if 0), sortable - Created At: Formatted timestamp (e.g., \"Jan 15, 2026 10:30 AM\"), sortable - Actions: 4 buttons (Edit, View Locations, Export, Delete), 300px width, fixed right

"},{"location":"v2/frontend/pages/admin/cuts-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#local-state-no-zustand-store","title":"Local State (No Zustand Store)","text":"
// View state\nconst [activeTab, setActiveTab] = useState<string>('table');\n\n// Data state\nconst [cuts, setCuts] = useState<Cut[]>([]);\nconst [loading, setLoading] = useState(false);\n\n// Filter state\nconst [search, setSearch] = useState('');\n\n// Pagination state\nconst [pagination, setPagination] = useState({\n  current: 1,\n  pageSize: 10,\n  total: 0,\n});\n\n// Modal state\nconst [createModalOpen, setCreateModalOpen] = useState(false);\nconst [editModalOpen, setEditModalOpen] = useState(false);\nconst [selectedCut, setSelectedCut] = useState<Cut | null>(null);\n\n// Import state\nconst [importing, setImporting] = useState(false);\n\n// Debounce timer\nconst searchTimerRef = useRef<NodeJS.Timeout | null>(null);\n

No Global State:

This page does NOT use Zustand stores. Cut data is fetched directly from the API on mount and after mutations. This is appropriate because: - Cut data is admin-only (not needed globally) - Data changes infrequently (only on manual create/edit/delete) - No need to share state between pages (LocationsPage fetches cuts independently) - Simpler architecture without store overhead

"},{"location":"v2/frontend/pages/admin/cuts-page/#debounced-search-pattern","title":"Debounced Search Pattern","text":"
const handleSearch = (value: string) => {\n  // Clear existing timer\n  if (searchTimerRef.current) {\n    clearTimeout(searchTimerRef.current);\n  }\n\n  // Set new timer\n  searchTimerRef.current = setTimeout(() => {\n    setSearch(value);\n    setPagination((prev) => ({ ...prev, current: 1 })); // Reset to page 1\n  }, 300);\n};\n\n// Cleanup on unmount\nuseEffect(() => {\n  return () => {\n    if (searchTimerRef.current) clearTimeout(searchTimerRef.current);\n  };\n}, []);\n

Why 300ms Debounce?

"},{"location":"v2/frontend/pages/admin/cuts-page/#usecallback-optimization","title":"useCallback Optimization","text":"
const loadCuts = useCallback(async () => {\n  setLoading(true);\n  try {\n    const params: Record<string, unknown> = {\n      page: pagination.current,\n      limit: pagination.pageSize,\n    };\n\n    if (search) params.search = search;\n\n    const { data } = await api.get<{\n      data: Cut[];\n      pagination: { total: number };\n    }>('/cuts', { params });\n\n    setCuts(data.data);\n    setPagination((prev) => ({\n      ...prev,\n      total: data.pagination.total,\n    }));\n  } catch (error) {\n    message.error('Failed to load cuts');\n  } finally {\n    setLoading(false);\n  }\n}, [pagination.current, pagination.pageSize, search]);\n\nuseEffect(() => {\n  if (activeTab === 'table') {\n    loadCuts();\n  }\n}, [activeTab, loadCuts]);\n

Conditional Loading:

Cuts only load when Table tab is active. Map view uses separate data fetching in CutEditorMap component.

"},{"location":"v2/frontend/pages/admin/cuts-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose Auth GET /api/cuts List cuts (paginated, filtered) Required GET /api/cuts/:id Get single cut with geometry Required POST /api/cuts Create new cut Required PUT /api/cuts/:id Update cut metadata Required DELETE /api/cuts/:id Delete cut Required POST /api/cuts/import Import cuts from GeoJSON Required GET /api/cuts/:id/export Export cut to GeoJSON Required"},{"location":"v2/frontend/pages/admin/cuts-page/#load-cuts-paginated-with-search","title":"Load Cuts (Paginated with Search)","text":"

Request:

const params: Record<string, unknown> = {\n  page: 1,\n  limit: 10,\n  search: 'Downtown',  // Optional: search query\n};\n\nconst { data } = await api.get<{\n  data: Cut[];\n  pagination: { total: number; page: number; limit: number };\n}>('/cuts', { params });\n

Query Parameters: - page (number, required): Page number (1-indexed) - limit (number, required): Items per page (10, 25, 50, or 100) - search (string, optional): Search query (matches name, description)

Response (200 OK):

{\n  \"data\": [\n    {\n      \"id\": \"cut_abc123\",\n      \"name\": \"Downtown Core\",\n      \"description\": \"High-density residential area with apartment buildings\",\n      \"color\": \"#3498db\",\n      \"geometry\": {\n        \"type\": \"Polygon\",\n        \"coordinates\": [\n          [\n            [-75.69602, 45.42153],\n            [-75.69102, 45.42153],\n            [-75.69102, 45.41653],\n            [-75.69602, 45.41653],\n            [-75.69602, 45.42153]\n          ]\n        ]\n      },\n      \"locationCount\": 47,\n      \"createdAt\": \"2026-01-15T10:30:00.000Z\",\n      \"updatedAt\": \"2026-01-15T10:30:00.000Z\"\n    },\n    {\n      \"id\": \"cut_def456\",\n      \"name\": \"Riverside District\",\n      \"description\": null,\n      \"color\": \"#e74c3c\",\n      \"geometry\": {\n        \"type\": \"Polygon\",\n        \"coordinates\": [\n          [\n            [-75.70602, 45.43153],\n            [-75.70102, 45.43153],\n            [-75.70102, 45.42653],\n            [-75.70602, 45.42653],\n            [-75.70602, 45.43153]\n          ]\n        ]\n      },\n      \"locationCount\": 32,\n      \"createdAt\": \"2026-01-16T14:20:00.000Z\",\n      \"updatedAt\": \"2026-01-16T14:20:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 10,\n    \"total\": 12\n  }\n}\n

Response Fields:

"},{"location":"v2/frontend/pages/admin/cuts-page/#create-cut","title":"Create Cut","text":"

Request:

const cutData = {\n  name: 'Downtown Core',\n  description: 'High-density residential area',\n  color: '#3498db',\n  geometry: {\n    type: 'Polygon',\n    coordinates: [\n      [\n        [-75.69602, 45.42153],\n        [-75.69102, 45.42153],\n        [-75.69102, 45.41653],\n        [-75.69602, 45.41653],\n        [-75.69602, 45.42153]  // Closed polygon (first point = last point)\n      ]\n    ]\n  }\n};\n\nconst { data } = await api.post<{\n  message: string;\n  cut: Cut;\n  locationCount: number;\n}>('/cuts', cutData);\n

Request Body Schema:

{\n  name: string;           // Required, min 1 char, max 255 chars\n  description?: string;   // Optional, max 1000 chars\n  color: string;          // Required, hex color code (e.g., \"#3498db\")\n  geometry: {             // Required, GeoJSON Polygon\n    type: 'Polygon';\n    coordinates: number[][][];  // [[[lng, lat], [lng, lat], ...]]\n  };\n}\n

Validation Rules:

Response (201 Created):

{\n  \"message\": \"Cut created successfully with 47 locations\",\n  \"cut\": {\n    \"id\": \"cut_abc123\",\n    \"name\": \"Downtown Core\",\n    \"description\": \"High-density residential area\",\n    \"color\": \"#3498db\",\n    \"geometry\": {\n      \"type\": \"Polygon\",\n      \"coordinates\": [[[...]]\n    },\n    \"locationCount\": 47,\n    \"createdAt\": \"2026-01-15T10:30:00.000Z\",\n    \"updatedAt\": \"2026-01-15T10:30:00.000Z\"\n  },\n  \"locationCount\": 47\n}\n

Backend Workflow:

// 1. Validate polygon geometry\nconst isValidPolygon = validatePolygonGeometry(geometry);\nif (!isValidPolygon) {\n  throw new Error('Invalid polygon geometry (self-intersecting or unclosed)');\n}\n\n// 2. Create Cut record\nconst cut = await prisma.cut.create({\n  data: {\n    name,\n    description,\n    color,\n    geometry: geometry as unknown as Prisma.InputJsonValue,\n  },\n});\n\n// 3. Find locations within polygon (point-in-polygon algorithm)\nconst allLocations = await prisma.location.findMany({\n  where: { deletedAt: null },\n});\n\nconst locationsInCut = allLocations.filter((location) => {\n  if (!location.latitude || !location.longitude) return false;\n  return isPointInPolygon(\n    [location.longitude, location.latitude],\n    geometry.coordinates[0]\n  );\n});\n\n// 4. Assign locations to cut\nawait prisma.location.updateMany({\n  where: {\n    id: { in: locationsInCut.map((l) => l.id) },\n  },\n  data: { cutId: cut.id },\n});\n\n// 5. Return cut with location count\nreturn {\n  message: `Cut created successfully with ${locationsInCut.length} locations`,\n  cut,\n  locationCount: locationsInCut.length,\n};\n
"},{"location":"v2/frontend/pages/admin/cuts-page/#update-cut-metadata","title":"Update Cut Metadata","text":"

Request:

const cutId = 'cut_abc123';\nconst updates = {\n  name: 'Downtown Core (Updated)',\n  description: 'Updated description',\n  color: '#2ecc71',  // New color\n};\n\nconst { data } = await api.put<Cut>(`/cuts/${cutId}`, updates);\n

Request Body Schema:

{\n  name?: string;           // Optional, min 1 char, max 255 chars\n  description?: string;    // Optional, max 1000 chars\n  color?: string;          // Optional, hex color code\n  // Note: geometry cannot be updated (must delete and recreate)\n}\n

Response (200 OK):

{\n  \"id\": \"cut_abc123\",\n  \"name\": \"Downtown Core (Updated)\",\n  \"description\": \"Updated description\",\n  \"color\": \"#2ecc71\",\n  \"geometry\": {\n    \"type\": \"Polygon\",\n    \"coordinates\": [[[...]]]\n  },\n  \"locationCount\": 47,\n  \"createdAt\": \"2026-01-15T10:30:00.000Z\",\n  \"updatedAt\": \"2026-01-15T12:45:00.000Z\"\n}\n

Important: Updating cut metadata does NOT recalculate locations within polygon. Geometry cannot be updated via this endpoint (must delete cut and create new one with new geometry).

"},{"location":"v2/frontend/pages/admin/cuts-page/#delete-cut","title":"Delete Cut","text":"

Request:

const cutId = 'cut_abc123';\nawait api.delete(`/cuts/${cutId}`);\n

URL Parameter: - id (string): Cut ID to delete

Response (200 OK):

{\n  \"message\": \"Cut deleted successfully\",\n  \"unassignedLocations\": 47\n}\n

Response Fields: - message (string): Confirmation message - unassignedLocations (number): Number of locations that were unassigned from cut

Backend Workflow:

// 1. Delete Cut record\nawait prisma.cut.delete({\n  where: { id: cutId },\n});\n\n// 2. Unassign locations (set cutId = null)\nconst unassignedCount = await prisma.location.updateMany({\n  where: { cutId },\n  data: { cutId: null },\n});\n\n// 3. Delete associated shifts (cascade delete)\nawait prisma.shift.deleteMany({\n  where: { cutId },\n});\n\nreturn {\n  message: 'Cut deleted successfully',\n  unassignedLocations: unassignedCount.count,\n};\n

Cascade Effects: - Locations: Unassigned (cutId set to null), NOT deleted - Shifts: Deleted (shifts are cut-specific, meaningless without cut) - Canvass Sessions: Closed/abandoned (sessions reference cutId)

"},{"location":"v2/frontend/pages/admin/cuts-page/#import-cuts-from-geojson","title":"Import Cuts from GeoJSON","text":"

Request:

const formData = new FormData();\nformData.append('file', geoJsonFile);  // File object from <input type=\"file\">\n\nconst { data } = await api.post<{\n  message: string;\n  importedCuts: number;\n  totalLocations: number;\n}>('/cuts/import', formData, {\n  headers: { 'Content-Type': 'multipart/form-data' },\n});\n

Request Body: - file (File): GeoJSON file (FeatureCollection with Polygon features)

Expected GeoJSON Format:

{\n  \"type\": \"FeatureCollection\",\n  \"features\": [\n    {\n      \"type\": \"Feature\",\n      \"geometry\": {\n        \"type\": \"Polygon\",\n        \"coordinates\": [\n          [\n            [-75.69602, 45.42153],\n            [-75.69102, 45.42153],\n            [-75.69102, 45.41653],\n            [-75.69602, 45.41653],\n            [-75.69602, 45.42153]\n          ]\n        ]\n      },\n      \"properties\": {\n        \"name\": \"Downtown Core\",\n        \"description\": \"High-density residential area\",\n        \"color\": \"#3498db\"\n      }\n    },\n    {\n      \"type\": \"Feature\",\n      \"geometry\": {\n        \"type\": \"Polygon\",\n        \"coordinates\": [[...]]\n      },\n      \"properties\": {\n        \"name\": \"Riverside District\",\n        \"description\": null,\n        \"color\": \"#e74c3c\"\n      }\n    }\n  ]\n}\n

Response (200 OK):

{\n  \"message\": \"Imported 2 cuts with 79 total locations\",\n  \"importedCuts\": 2,\n  \"totalLocations\": 79,\n  \"details\": [\n    {\n      \"cutId\": \"cut_abc123\",\n      \"name\": \"Downtown Core\",\n      \"locationCount\": 47\n    },\n    {\n      \"cutId\": \"cut_def456\",\n      \"name\": \"Riverside District\",\n      \"locationCount\": 32\n    }\n  ]\n}\n

Error Response (400 Bad Request) - Invalid GeoJSON:

{\n  \"error\": \"Validation Error\",\n  \"message\": \"Invalid GeoJSON format. Expected FeatureCollection with Polygon features.\"\n}\n

Backend Workflow:

// 1. Parse GeoJSON file\nconst geoJson = JSON.parse(fileContent);\nif (geoJson.type !== 'FeatureCollection') {\n  throw new Error('Expected GeoJSON FeatureCollection');\n}\n\n// 2. Validate features\nconst polygonFeatures = geoJson.features.filter(\n  (f) => f.geometry.type === 'Polygon'\n);\nif (polygonFeatures.length === 0) {\n  throw new Error('No Polygon features found in GeoJSON');\n}\n\n// 3. Import each feature as a Cut\nconst importResults = [];\nfor (const feature of polygonFeatures) {\n  const cut = await prisma.cut.create({\n    data: {\n      name: feature.properties.name || 'Untitled Cut',\n      description: feature.properties.description || null,\n      color: feature.properties.color || '#3498db',\n      geometry: feature.geometry as unknown as Prisma.InputJsonValue,\n    },\n  });\n\n  // 4. Assign locations to cut\n  const locationCount = await assignLocationsToCut(cut.id, feature.geometry);\n\n  importResults.push({\n    cutId: cut.id,\n    name: cut.name,\n    locationCount,\n  });\n}\n\n// 5. Return summary\nreturn {\n  message: `Imported ${importResults.length} cuts with ${totalLocations} total locations`,\n  importedCuts: importResults.length,\n  totalLocations,\n  details: importResults,\n};\n
"},{"location":"v2/frontend/pages/admin/cuts-page/#export-cut-to-geojson","title":"Export Cut to GeoJSON","text":"

Request:

const cutId = 'cut_abc123';\nconst { data } = await api.get<GeoJSON.Feature>(`/cuts/${cutId}/export`);\n\n// Convert to JSON string and download\nconst blob = new Blob([JSON.stringify(data, null, 2)], {\n  type: 'application/geo+json',\n});\nconst url = URL.createObjectURL(blob);\nconst a = document.createElement('a');\na.href = url;\na.download = `cut-${data.properties.name}-${cutId}.geojson`;\na.click();\nURL.revokeObjectURL(url);\n

Response (200 OK):

{\n  \"type\": \"Feature\",\n  \"geometry\": {\n    \"type\": \"Polygon\",\n    \"coordinates\": [\n      [\n        [-75.69602, 45.42153],\n        [-75.69102, 45.42153],\n        [-75.69102, 45.41653],\n        [-75.69602, 45.41653],\n        [-75.69602, 45.42153]\n      ]\n    ]\n  },\n  \"properties\": {\n    \"id\": \"cut_abc123\",\n    \"name\": \"Downtown Core\",\n    \"description\": \"High-density residential area\",\n    \"color\": \"#3498db\",\n    \"locationCount\": 47,\n    \"createdAt\": \"2026-01-15T10:30:00.000Z\",\n    \"updatedAt\": \"2026-01-15T10:30:00.000Z\"\n  }\n}\n

GeoJSON Feature Structure: - type: \"Feature\" (GeoJSON standard) - geometry: Polygon geometry with coordinates - properties: Cut metadata including location count, timestamps

"},{"location":"v2/frontend/pages/admin/cuts-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#complete-cut-creation-flow-map-view","title":"Complete Cut Creation Flow (Map View)","text":"
const handleSaveCut = async (polygon: LatLng[], formValues: { name: string; description?: string; color: string }) => {\n  try {\n    // 1. Convert Leaflet LatLng array to GeoJSON coordinates\n    const coordinates = polygon.map((point) => [point.lng, point.lat]);\n\n    // 2. Close polygon (first point = last point)\n    if (\n      coordinates[0][0] !== coordinates[coordinates.length - 1][0] ||\n      coordinates[0][1] !== coordinates[coordinates.length - 1][1]\n    ) {\n      coordinates.push(coordinates[0]);\n    }\n\n    // 3. Create GeoJSON geometry\n    const geometry: GeoJSON.Polygon = {\n      type: 'Polygon',\n      coordinates: [coordinates],\n    };\n\n    // 4. Validate polygon (minimum 3 vertices)\n    if (coordinates.length < 4) {  // 4 including closing point\n      message.error('Polygon must have at least 3 vertices');\n      return;\n    }\n\n    // 5. Create cut via API\n    const { data } = await api.post<{\n      message: string;\n      cut: Cut;\n      locationCount: number;\n    }>('/cuts', {\n      name: formValues.name,\n      description: formValues.description || null,\n      color: formValues.color,\n      geometry,\n    });\n\n    message.success(data.message);\n\n    // 6. Refresh cuts on map\n    await loadCuts();\n\n    // 7. Reset drawing mode\n    setDrawingMode(false);\n  } catch (error) {\n    if (axios.isAxiosError(error) && error.response?.status === 400) {\n      message.error('Invalid polygon geometry. Ensure polygon is closed and does not self-intersect.');\n    } else {\n      message.error('Failed to create cut');\n    }\n  }\n};\n

Key Steps: 1. Convert Leaflet LatLng objects to [lng, lat] arrays (GeoJSON format) 2. Ensure polygon is closed (first point = last point) 3. Wrap coordinates in GeoJSON Polygon structure 4. Validate minimum vertex count (3 vertices + 1 closing point = 4 coordinates) 5. Send POST request with geometry + metadata 6. Refresh map to show new polygon overlay 7. Exit drawing mode to allow normal map interaction

"},{"location":"v2/frontend/pages/admin/cuts-page/#geojson-import-flow","title":"GeoJSON Import Flow","text":"
const handleImportGeoJSON = async (file: File) => {\n  setImporting(true);\n  try {\n    // 1. Create FormData\n    const formData = new FormData();\n    formData.append('file', file);\n\n    // 2. Upload to backend\n    const { data } = await api.post<{\n      message: string;\n      importedCuts: number;\n      totalLocations: number;\n      details: Array<{ cutId: string; name: string; locationCount: number }>;\n    }>('/cuts/import', formData, {\n      headers: { 'Content-Type': 'multipart/form-data' },\n    });\n\n    // 3. Show detailed success message\n    message.success(data.message);\n\n    // 4. Log import details\n    console.log('Import details:', data.details);\n\n    // 5. Refresh table\n    await loadCuts();\n  } catch (error) {\n    if (axios.isAxiosError(error) && error.response?.status === 400) {\n      message.error('Invalid GeoJSON format. Expected FeatureCollection with Polygon features.');\n    } else {\n      message.error('Failed to import GeoJSON');\n    }\n  } finally {\n    setImporting(false);\n  }\n};\n

Error Handling: - 400 Bad Request: Invalid GeoJSON format (not FeatureCollection, no Polygon features) - 500 Internal Server Error: Server error during import (e.g., database error, polygon validation failure)

"},{"location":"v2/frontend/pages/admin/cuts-page/#geojson-export-flow","title":"GeoJSON Export Flow","text":"
const handleExportGeoJSON = async (cut: Cut) => {\n  try {\n    // 1. Fetch cut with geometry\n    const { data } = await api.get<GeoJSON.Feature>(`/cuts/${cut.id}/export`);\n\n    // 2. Convert to JSON string (pretty-printed)\n    const geoJsonString = JSON.stringify(data, null, 2);\n\n    // 3. Create Blob\n    const blob = new Blob([geoJsonString], {\n      type: 'application/geo+json',\n    });\n\n    // 4. Create download link\n    const url = URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = `cut-${cut.name.replace(/\\s+/g, '-').toLowerCase()}-${cut.id}.geojson`;\n\n    // 5. Trigger download\n    document.body.appendChild(a);\n    a.click();\n\n    // 6. Cleanup\n    document.body.removeChild(a);\n    URL.revokeObjectURL(url);\n\n    message.success(`Exported \"${cut.name}\" to GeoJSON`);\n  } catch (error) {\n    message.error('Failed to export GeoJSON');\n  }\n};\n

File Naming: - Pattern: cut-{name}-{id}.geojson - Example: cut-downtown-core-cut_abc123.geojson - Spaces in name replaced with hyphens - Lowercase for consistency

"},{"location":"v2/frontend/pages/admin/cuts-page/#delete-with-cascade-warning","title":"Delete with Cascade Warning","text":"
const handleDeleteConfirm = (cut: Cut) => {\n  Modal.confirm({\n    title: 'Delete Cut',\n    content: (\n      <div>\n        <p>Are you sure you want to delete the cut <strong>\"{cut.name}\"</strong>?</p>\n        <p style={{ marginTop: 8, color: '#ff4d4f' }}>\n          <WarningOutlined /> This will:\n        </p>\n        <ul style={{ marginTop: 4, paddingLeft: 20 }}>\n          <li>Unassign all {cut.locationCount} locations from this cut</li>\n          <li>Delete all associated shifts</li>\n          <li>Close any active canvass sessions in this cut</li>\n        </ul>\n        <p style={{ marginTop: 8 }}>Locations will NOT be deleted (only unassigned).</p>\n      </div>\n    ),\n    okText: 'Delete',\n    okType: 'danger',\n    cancelText: 'Cancel',\n    width: 520,\n    onOk: async () => {\n      try {\n        const { data } = await api.delete<{\n          message: string;\n          unassignedLocations: number;\n        }>(`/cuts/${cut.id}`);\n\n        message.success(`Cut deleted successfully. ${data.unassignedLocations} locations unassigned.`);\n\n        // Refresh both table and map views\n        await loadCuts();\n      } catch (error) {\n        message.error('Failed to delete cut');\n      }\n    },\n  });\n};\n

Enhanced Confirmation: - Shows cut name for clarity - Lists all cascade effects (unassign locations, delete shifts, close sessions) - Clarifies that locations are NOT deleted (only unassigned) - Uses danger button styling - Wider modal (520px) to accommodate detailed content

"},{"location":"v2/frontend/pages/admin/cuts-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#lazy-map-loading","title":"Lazy Map Loading","text":"

Map view only loads when tab is active:

useEffect(() => {\n  if (activeTab === 'map') {\n    // Load cuts for map visualization\n    loadCutsForMap();\n  }\n}, [activeTab]);\n

Benefits: - Faster initial page load: Leaflet map library not loaded until needed - Reduced memory: Map tiles not downloaded until Map tab clicked - Better UX: Table view loads instantly without map overhead

"},{"location":"v2/frontend/pages/admin/cuts-page/#server-side-pagination","title":"Server-Side Pagination","text":"

Table uses server-side pagination to handle large cut datasets:

const { data } = await api.get('/cuts', {\n  params: {\n    page: pagination.current,\n    limit: pagination.pageSize,\n    search,\n  },\n});\n

Scalability: - Works efficiently with 10 to 1,000+ cuts - Only fetches current page (10-100 items) - Backend applies search filter before pagination

"},{"location":"v2/frontend/pages/admin/cuts-page/#debounced-search-300ms","title":"Debounced Search (300ms)","text":"

Prevents API spam during typing:

searchTimerRef.current = setTimeout(() => {\n  setSearch(value);\n}, 300);\n

Performance Impact: - Without debounce: Typing \"Downtown Core\" (13 chars) = 13 API calls - With 300ms debounce: Typing \"Downtown Core\" = 1 API call - 92% reduction in API requests

"},{"location":"v2/frontend/pages/admin/cuts-page/#polygon-simplification-future-enhancement","title":"Polygon Simplification (Future Enhancement)","text":"

For cuts with 1,000+ vertices (very detailed polygons), consider simplifying geometry:

// Using Turf.js library\nimport { simplify } from '@turf/simplify';\n\nconst simplifiedPolygon = simplify(polygon, {\n  tolerance: 0.0001,  // Degrees (~10 meters)\n  highQuality: false,\n});\n

Benefits: - Reduces GeoJSON payload size - Faster map rendering (fewer vertices to draw) - Maintains visual accuracy for canvassing purposes

"},{"location":"v2/frontend/pages/admin/cuts-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#mobile-table-layout","title":"Mobile Table Layout","text":"

Table adapts to mobile viewports:

{\n  title: 'Description',\n  dataIndex: 'description',\n  responsive: ['md'],  // Hidden on mobile\n  ellipsis: true,\n  render: (text) => text || '\u2014',\n},\n{\n  title: 'Location Count',\n  dataIndex: 'locationCount',\n  responsive: ['sm'],  // Visible on tablet+\n  render: (count) => <Tag>{count} locations</Tag>,\n}\n

Mobile Columns (xs): - Name (visible) - Color (visible) - Actions (visible, wrapped)

Tablet Columns (sm+): - Name + Color + Location Count + Actions

Desktop Columns (md+): - Name + Description + Color + Location Count + Created At + Actions

"},{"location":"v2/frontend/pages/admin/cuts-page/#full-screen-map-view","title":"Full-Screen Map View","text":"

Map view uses full available height:

<div style={{ height: 'calc(100vh - 200px)', width: '100%' }}>\n  <CutEditorMap\n    cuts={cuts}\n    onSaveCut={handleSaveCut}\n  />\n</div>\n

Calculation: - 100vh: Full viewport height - -200px: Subtract header (64px) + page title (48px) + segmented control (48px) + margins (40px) - Result: Map fills remaining vertical space

"},{"location":"v2/frontend/pages/admin/cuts-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#keyboard-navigation","title":"Keyboard Navigation","text":"

All interactive elements are keyboard-accessible:

Segmented Control: - Tab: Focus on segmented control - Arrow Keys: Switch between Table and Map tabs - Enter/Space: Activate selected tab

Table Navigation: - Tab: Move between action buttons (Edit, View, Export, Delete) - Enter/Space: Activate focused button - Arrow Keys: Navigate table rows

Map Drawing: - Escape: Cancel drawing mode - Enter: Complete polygon (after placing 3+ vertices) - Backspace: Remove last vertex

"},{"location":"v2/frontend/pages/admin/cuts-page/#screen-reader-support","title":"Screen Reader Support","text":"

All elements have proper ARIA labels:

Action Buttons:

<Button\n  icon={<EditOutlined />}\n  onClick={() => handleEdit(cut)}\n  aria-label={`Edit cut ${cut.name}`}\n>\n  Edit\n</Button>\n\n<Button\n  icon={<EnvironmentOutlined />}\n  onClick={() => handleViewLocations(cut)}\n  aria-label={`View ${cut.locationCount} locations in cut ${cut.name}`}\n>\n  View Locations\n</Button>\n

Color Preview:

<div\n  style={{ backgroundColor: cut.color }}\n  aria-label={`Cut color: ${cut.color}`}\n  role=\"img\"\n/>\n

"},{"location":"v2/frontend/pages/admin/cuts-page/#focus-indicators","title":"Focus Indicators","text":"

All interactive elements have visible focus states:

Buttons:

.ant-btn:focus {\n  outline: 2px solid #1890ff;\n  outline-offset: 2px;\n}\n

Segmented Control:

.ant-segmented-item:focus {\n  outline: 2px solid #1890ff;\n  outline-offset: 2px;\n}\n

"},{"location":"v2/frontend/pages/admin/cuts-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#polygon-wont-close","title":"Polygon Won't Close","text":"

Problem: Drawing polygon on map, clicked 5+ vertices, but polygon won't close automatically.

Diagnosis:

Check if first vertex is being clicked:

// Close detection radius: 10 pixels\nconst distanceToFirst = Math.sqrt(\n  Math.pow(clickX - firstVertexX, 2) + Math.pow(clickY - firstVertexY, 2)\n);\n\nif (distanceToFirst <= 10) {\n  // Close polygon\n}\n

Possible Causes:

  1. Click not close enough to first vertex:
  2. Must click within 10 pixels of first vertex marker
  3. First vertex marker may be small or obscured

  4. Double-click required:

  5. Some users expect double-click to close polygon
  6. Single-click on first vertex should work but may feel unintuitive

  7. Drawing mode not active:

  8. Forgot to click \"Draw New Cut\" button first
  9. Drawing mode indicator not visible

Solution:

  1. For close detection:
  2. Click directly on the blue circle marker (first vertex)
  3. Or double-click anywhere to force close polygon
  4. Ensure at least 3 vertices placed before closing

  5. Alternative closing methods:

  6. Press Enter key to close polygon (keyboard shortcut)
  7. Right-click and select \"Close Polygon\" from context menu (if implemented)

  8. Visual feedback:

  9. First vertex marker should pulse or highlight when hovering nearby (indicates close detection active)
  10. Drawing mode indicator should show \"Click first vertex to close\" text
"},{"location":"v2/frontend/pages/admin/cuts-page/#import-geojson-fails","title":"Import GeoJSON Fails","text":"

Problem: Click \"Import GeoJSON\", select file, get error: \"Invalid GeoJSON format\".

Diagnosis:

Check GeoJSON structure:

// Valid GeoJSON FeatureCollection\n{\n  \"type\": \"FeatureCollection\",\n  \"features\": [...]\n}\n\n// Invalid: Single Feature (missing FeatureCollection wrapper)\n{\n  \"type\": \"Feature\",\n  \"geometry\": {...}\n}\n

Possible Causes:

  1. Single Feature instead of FeatureCollection:
  2. GeoJSON file contains single Feature, not FeatureCollection
  3. Backend expects FeatureCollection with multiple features

  4. Non-Polygon geometries:

  5. GeoJSON contains Point, LineString, or MultiPolygon features
  6. Backend only supports Polygon geometry type

  7. Missing required properties:

  8. Feature properties don't include \"name\" field
  9. Backend requires name to create cut

  10. Invalid JSON syntax:

  11. Trailing commas, missing quotes, incorrect brackets
  12. JSON parser cannot read file

Solution:

  1. For single Feature:
  2. Wrap in FeatureCollection:

    {\n  \"type\": \"FeatureCollection\",\n  \"features\": [\n    {\n      \"type\": \"Feature\",\n      \"geometry\": {...},\n      \"properties\": {...}\n    }\n  ]\n}\n

  3. For non-Polygon geometries:

  4. Convert Point/LineString to Polygon using GIS software (QGIS, ArcGIS)
  5. Or manually edit GeoJSON to create Polygon boundaries

  6. For missing properties:

  7. Add \"name\" property to each Feature:

    \"properties\": {\n  \"name\": \"Untitled Cut\",\n  \"description\": \"\",\n  \"color\": \"#3498db\"\n}\n

  8. For invalid JSON:

  9. Validate JSON syntax using online tool (jsonlint.com)
  10. Fix any syntax errors before importing
"},{"location":"v2/frontend/pages/admin/cuts-page/#locations-not-appearing-in-cut","title":"Locations Not Appearing in Cut","text":"

Problem: Create cut polygon, success message says \"Cut created successfully with 0 locations\", but there should be locations within boundary.

Diagnosis:

Check location coordinates vs. polygon coordinates:

// Example: Location at [45.42, -75.69] (lat, lng)\n// Polygon coordinates: [[-75.69, 45.42], ...] (lng, lat)\n\n// Are coordinates in correct order?\n// Is location actually within polygon boundary?\n

Possible Causes:

  1. Coordinate order confusion:
  2. Location stored as [lat, lng] but polygon uses [lng, lat] (GeoJSON standard)
  3. Point-in-polygon algorithm receives wrong coordinate order

  4. Locations not geocoded:

  5. Locations have null latitude/longitude values
  6. Cannot check if point is in polygon without coordinates

  7. Polygon too small:

  8. Drew very small polygon that doesn't actually contain any location markers
  9. Zoom in on map to verify polygon size vs. location density

  10. Precision issues:

  11. Location coordinates have low precision (e.g., rounded to 2 decimal places)
  12. Polygon boundary is at edge of location, but point-in-polygon check fails due to rounding

Solution:

  1. For coordinate order:
  2. Verify backend point-in-polygon function uses correct order:

    isPointInPolygon(\n  [location.longitude, location.latitude],  // [lng, lat] order\n  polygon.coordinates[0]\n);\n

  3. For missing coordinates:

  4. Run geocoding on locations before assigning to cut
  5. Navigate to LocationsPage, bulk geocode locations, then create cut

  6. For small polygons:

  7. Zoom in on map to see location markers
  8. Draw larger polygon that clearly encompasses location clusters
  9. Use Location Count filter on LocationsPage to verify locations exist in area

  10. For precision issues:

  11. Use higher precision coordinates (6+ decimal places = ~0.1 meter accuracy)
  12. Slightly expand polygon boundary to account for rounding errors
"},{"location":"v2/frontend/pages/admin/cuts-page/#delete-cut-fails-with-constraint-error","title":"Delete Cut Fails with Constraint Error","text":"

Problem: Click \"Delete\" button, confirm deletion, get error: \"Failed to delete cut. Constraint violation.\"

Diagnosis:

Check database foreign key constraints:

-- Check for references to cut\nSELECT COUNT(*) FROM \"Shift\" WHERE \"cutId\" = 'cut_abc123';\nSELECT COUNT(*) FROM \"CanvassSession\" WHERE \"cutId\" = 'cut_abc123';\n

Possible Causes:

  1. Active shifts:
  2. Shift records reference this cutId
  3. Foreign key constraint prevents deletion

  4. Active canvass sessions:

  5. CanvassSession records reference this cutId
  6. Sessions must be closed/deleted before cut can be deleted

  7. Database migration issue:

  8. Foreign key constraints not set to CASCADE
  9. Deletion of parent record (Cut) should cascade to child records (Shift, CanvassSession)

Solution:

  1. For active shifts:
  2. Navigate to /app/map/shifts
  3. Filter by cut name
  4. Delete all shifts in this cut
  5. Return to CutsPage and retry delete

  6. For active sessions:

  7. Navigate to /app/canvass/dashboard
  8. Find active sessions in this cut
  9. Close or abandon sessions
  10. Return to CutsPage and retry delete

  11. For migration issue (developer fix):

  12. Update Prisma schema to add cascade delete:
    model Shift {\n  cutId String?\n  cut   Cut?    @relation(fields: [cutId], references: [id], onDelete: Cascade)\n}\n\nmodel CanvassSession {\n  cutId String?\n  cut   Cut?    @relation(fields: [cutId], references: [id], onDelete: Cascade)\n}\n
  13. Run migration: npx prisma migrate dev
"},{"location":"v2/frontend/pages/admin/cuts-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/","title":"DashboardPage","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#overview","title":"Overview","text":"

The DashboardPage serves as the landing page for authenticated admin users after login. It provides a high-level overview of key metrics across all modules (Users, Campaigns, Locations, Emails) using statistic cards. Currently displays placeholder values with a notice that full analytics are coming soon.

Route: /app Component: admin/src/pages/DashboardPage.tsx (67 lines) Auth Required: Yes (any authenticated admin role) Layout: AppLayout

"},{"location":"v2/frontend/pages/admin/dashboard-page/#screenshot","title":"Screenshot","text":"

[Screenshot: Dashboard with 4 statistic cards in a responsive grid showing Total Users, Active Campaigns, Map Locations, and Emails Sent. Below the cards is an info alert explaining that analytics are coming soon. The page shows a personalized welcome message \"Welcome, [User Name]\" at the top.]

"},{"location":"v2/frontend/pages/admin/dashboard-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#viewing-dashboard-current-state","title":"Viewing Dashboard (Current State)","text":"
  1. User logs in and is redirected to /app
  2. Dashboard loads with personalized greeting
  3. Four metric cards display with placeholder values (\"--\")
  4. Info alert explains that analytics are coming soon
"},{"location":"v2/frontend/pages/admin/dashboard-page/#planned-workflow-future-enhancement","title":"Planned Workflow (Future Enhancement)","text":"
  1. User logs in and is redirected to /app
  2. Dashboard fetches real-time statistics from API
  3. Metric cards populate with actual values
  4. Charts and graphs display below cards (planned)
  5. Recent activity feed shows latest actions (planned)
"},{"location":"v2/frontend/pages/admin/dashboard-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#ant-design-components-used","title":"Ant Design Components Used","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#component-structure","title":"Component Structure","text":"
<>\n  <Title level={4}>Welcome{user?.name ? `, ${user.name}` : ''}</Title>\n\n  <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>\n    <Col xs={24} sm={12} lg={6}>\n      <Card>\n        <Statistic title=\"Total Users\" value=\"--\" prefix={<TeamOutlined />} />\n      </Card>\n    </Col>\n    {/* 3 more similar cards */}\n  </Row>\n\n  <Alert\n    message=\"Dashboard analytics coming soon\"\n    description=\"Statistics and charts will be populated as additional modules are implemented.\"\n    type=\"info\"\n    showIcon\n  />\n</>\n
"},{"location":"v2/frontend/pages/admin/dashboard-page/#layout-breakpoints","title":"Layout Breakpoints","text":"Screen Size Columns Cards Per Row xs (< 576px) 24/24 1 sm (\u2265 576px) 12/24 2 lg (\u2265 992px) 6/24 4"},{"location":"v2/frontend/pages/admin/dashboard-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#zustand-stores-used","title":"Zustand Stores Used","text":"
import { useAuthStore } from '@/stores/auth.store';\n\nconst { user } = useAuthStore();\n
"},{"location":"v2/frontend/pages/admin/dashboard-page/#local-state","title":"Local State","text":"

None \u2014 Component is stateless, reads user from auth store only.

"},{"location":"v2/frontend/pages/admin/dashboard-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#current-implementation","title":"Current Implementation","text":"

No API calls \u2014 displays placeholder values.

"},{"location":"v2/frontend/pages/admin/dashboard-page/#planned-api-integration","title":"Planned API Integration","text":"

GET /api/dashboard/stats \u2014 Fetch dashboard statistics

Planned request:

const { data } = await api.get('/api/dashboard/stats');\n\n// Expected response:\n{\n  totalUsers: 45,\n  activeUsers: 32,\n  activeCampaigns: 8,\n  totalCampaigns: 12,\n  mapLocations: 1250,\n  emailsSent: 3420,\n  emailsQueued: 15,\n  queuedJobs: 3,\n  recentActivity: [...]\n}\n

"},{"location":"v2/frontend/pages/admin/dashboard-page/#code-example","title":"Code Example","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#adding-real-statistics-future-enhancement","title":"Adding Real Statistics (Future Enhancement)","text":"
import { useState, useEffect } from 'react';\nimport { api } from '@/lib/api';\n\nexport default function DashboardPage() {\n  const { user } = useAuthStore();\n  const [stats, setStats] = useState({\n    totalUsers: 0,\n    activeCampaigns: 0,\n    mapLocations: 0,\n    emailsSent: 0,\n  });\n  const [loading, setLoading] = useState(true);\n\n  useEffect(() => {\n    const fetchStats = async () => {\n      try {\n        const { data } = await api.get('/api/dashboard/stats');\n        setStats(data);\n      } catch (err) {\n        message.error('Failed to load dashboard statistics');\n      } finally {\n        setLoading(false);\n      }\n    };\n\n    fetchStats();\n  }, []);\n\n  return (\n    <>\n      <Title level={4}>Welcome{user?.name ? `, ${user.name}` : ''}</Title>\n\n      <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>\n        <Col xs={24} sm={12} lg={6}>\n          <Card>\n            <Statistic\n              title=\"Total Users\"\n              value={stats.totalUsers}\n              loading={loading}\n              prefix={<TeamOutlined />}\n            />\n          </Card>\n        </Col>\n        {/* ... other cards */}\n      </Row>\n    </>\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/dashboard-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#mobile-576px","title":"Mobile (< 576px)","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#tablet-576px-992px","title":"Tablet (576px - 992px)","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#desktop-992px","title":"Desktop (\u2265 992px)","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#current-performance","title":"Current Performance","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#planned-optimizations","title":"Planned Optimizations","text":"
import { Skeleton } from 'antd';\n\n{loading ? (\n  <Card>\n    <Skeleton.Input active size=\"small\" style={{ width: 100 }} />\n    <Skeleton.Input active size=\"large\" style={{ width: 60, marginTop: 8 }} />\n  </Card>\n) : (\n  <Card>\n    <Statistic title=\"Total Users\" value={stats.totalUsers} />\n  </Card>\n)}\n
"},{"location":"v2/frontend/pages/admin/dashboard-page/#future-enhancements","title":"Future Enhancements","text":"
  1. Real-time statistics \u2014 WebSocket updates for live metrics
  2. Charts and graphs \u2014 Trend visualizations (Chart.js or Recharts)
  3. Recent activity feed \u2014 List of latest actions across all modules
  4. Quick actions \u2014 Buttons for common tasks (Create Campaign, Add User, etc.)
  5. Module-specific widgets \u2014 Expandable cards with detailed stats
  6. Date range filter \u2014 View metrics for custom time periods
  7. Export dashboard \u2014 PDF report generation
"},{"location":"v2/frontend/pages/admin/dashboard-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/","title":"DataQualityDashboardPage","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#overview","title":"Overview","text":"

File: admin/src/pages/DataQualityDashboardPage.tsx Route: /app/map/data-quality Role Requirements: SUPER_ADMIN, MAP_ADMIN

DataQualityDashboardPage is a specialized dashboard for monitoring geocoding data quality across the location database. It displays comprehensive statistics about total locations, geocoding success rates, confidence levels, provider distribution, and building type breakdown. The page features auto-refresh every 30 seconds, responsive grid layout, and color-coded statistics cards for quick visual assessment of data quality.

The page provides insights into: - Total locations in database - Geocoding status (geocoded vs. ungeocoded) - Confidence levels (high/medium/low confidence, manual/none) - Provider distribution (Nominatim, ArcGIS, Photon, Google, Manual, etc.) - Building types (Single Family, Multi-Unit, Mixed Use, Commercial)

Key Features: - Auto-refresh every 30 seconds (no manual intervention needed) - Color-coded statistics (green=good, red=warning, gray=neutral) - Responsive grid layout (1-4 columns depending on screen size) - Refresh button for manual updates - Geocoded percentage calculation - Average confidence score with color thresholds

Key Components: - Ant Design Statistic cards for all metrics - Row/Col grid layout for responsive design - Typography for section headers - Auto-refresh interval with cleanup

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#screenshot","title":"Screenshot","text":"

[Screenshot: DataQualityDashboardPage showing four rows of statistics cards: 1) Overview row with Total Locations (blue), Geocoded with percentage (green), Ungeocoded (red if > 0), Average Confidence with color (green \u226585%, yellow 60-84%, red <60%); 2) Geocoding Confidence row with High Confidence (green, \u226585%), Medium Confidence (yellow, 60-84%), Low Confidence (red, <60%), Manual/None (gray); 3) Provider Distribution row showing counts for Nominatim, ArcGIS, Photon, Google, Manual; 4) Building Types row with Single Family (blue), Multi-Unit (green), Mixed Use (yellow), Commercial (purple). Refresh button in header.]

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#core-features","title":"Core Features","text":"
  1. Overview Statistics (4 cards)
  2. Total Locations: Count of all locations in database (blue)
  3. Geocoded: Count + percentage of geocoded locations (green)
  4. Ungeocoded: Count of locations without coordinates (red if > 0, gray if 0)
  5. Average Confidence: Percentage score with color-coded threshold (green \u226585%, yellow 60-84%, red <60%)

  6. Geocoding Confidence Breakdown (4 cards)

  7. High Confidence: Count of locations with \u226585% confidence (green)
  8. Medium Confidence: Count of locations with 60-84% confidence (yellow)
  9. Low Confidence: Count of locations with <60% confidence (red)
  10. Manual/None: Count of locations with no confidence score (gray)

  11. Provider Distribution (Dynamic cards)

  12. One card per geocoding provider (Nominatim, ArcGIS, Photon, Google, Manual, etc.)
  13. Capitalized provider names
  14. Count of locations geocoded by each provider
  15. Dynamic grid layout (adapts to number of providers)

  16. Building Type Distribution (4 cards)

  17. Single Family: Count of single-family residences (blue)
  18. Multi-Unit: Count of multi-unit residential buildings (green)
  19. Mixed Use: Count of mixed-use properties (yellow)
  20. Commercial: Count of commercial properties (purple)

  21. Auto-Refresh

  22. Refreshes every 30 seconds automatically
  23. Interval set with setInterval, cleaned up on unmount
  24. No loading spinner on auto-refresh (seamless updates)

  25. Manual Refresh

  26. Refresh button in page header
  27. Loading state during refresh
  28. Fetches latest statistics from API

  29. Responsive Grid Layout

  30. Desktop (\u2265768px): 4 columns per row
  31. Tablet (\u2265576px): 2 columns per row
  32. Mobile (<576px): 1 column per row
  33. Consistent gap (16px horizontal, 16px vertical)

  34. Color-Coded Statistics

  35. Green (#52c41a): Good/high confidence/geocoded
  36. Red (#ff4d4f): Warning/low confidence/ungeocoded
  37. Yellow (#faad14): Medium confidence
  38. Blue (#1890ff): Neutral/informational
  39. Gray (#8c8c8c): Neutral/none
  40. Purple (#722ed1): Commercial building type
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#viewing-data-quality-overview","title":"Viewing Data Quality Overview","text":"
  1. Navigate to page: Admin sidebar \u2192 Map \u2192 Data Quality
  2. Page loads: Initial statistics fetched and displayed
  3. Review overview cards:
  4. Check total locations count
  5. Verify geocoded percentage (aim for > 90%)
  6. Check if any ungeocoded locations (red warning)
  7. Review average confidence score (aim for \u226585%)
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#interpreting-confidence-levels","title":"Interpreting Confidence Levels","text":"

High Confidence (\u226585%): - Indicates accurate geocoding with precise coordinates - Green color = good data quality - Goal: Most locations should be in this category

Medium Confidence (60-84%): - Indicates acceptable geocoding but less precise - Yellow color = acceptable but could improve - Consider manual review or re-geocoding

Low Confidence (<60%): - Indicates poor geocoding accuracy - Red color = data quality issue - Action: Re-geocode with different provider or manually verify

Manual/None: - Manually entered coordinates or no confidence score - Gray color = neutral (may be accurate if manually verified)

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#monitoring-provider-performance","title":"Monitoring Provider Performance","text":"
  1. Check provider distribution cards:
  2. See which providers are most used
  3. Identify dominant provider (e.g., Nominatim: 8000, Google: 2000)
  4. Correlate with confidence levels:
  5. If high confidence count matches dominant provider, good sign
  6. If low confidence count high, may indicate provider issues
  7. Consider provider switching:
  8. Use LocationsPage to re-geocode low-confidence locations with different provider
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#reviewing-building-types","title":"Reviewing Building Types","text":"
  1. Check building type distribution:
  2. Single Family: Residential detached homes
  3. Multi-Unit: Apartments, condos, duplexes
  4. Mixed Use: Residential + commercial combo
  5. Commercial: Stores, offices, warehouses
  6. Verify data accuracy:
  7. Ensure building types match expected distribution for your area
  8. Flag anomalies (e.g., 0 single-family homes in suburban area)
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#using-auto-refresh","title":"Using Auto-Refresh","text":"
  1. Leave page open: Auto-refresh updates data every 30 seconds
  2. Monitor changes: Watch for new locations being added/geocoded
  3. No action needed: Data updates seamlessly without loading spinners
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#manual-refresh","title":"Manual Refresh","text":"
  1. Click Refresh button: In page header
  2. Loading state: Brief spinner or loading indicator
  3. Data updates: Latest statistics fetched from API
  4. Use case: Immediate update after bulk geocoding operation
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#overview-statistics-cards","title":"Overview Statistics Cards","text":"
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>\n  <Col xs={24} sm={12} md={6}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Total Locations\"\n        value={stats.total}\n        prefix={<EnvironmentOutlined />}\n        valueStyle={{ color: '#1890ff' }}\n      />\n    </Card>\n  </Col>\n  <Col xs={24} sm={12} md={6}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Geocoded\"\n        value={stats.geocoded}\n        suffix={\n          <Text type=\"secondary\" style={{ fontSize: 12 }}>\n            ({stats.total > 0 ? Math.round((stats.geocoded / stats.total) * 100) : 0}%)\n          </Text>\n        }\n        valueStyle={{ color: '#52c41a' }}\n      />\n    </Card>\n  </Col>\n  <Col xs={24} sm={12} md={6}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Ungeocoded\"\n        value={stats.ungeocoded}\n        valueStyle={{ color: stats.ungeocoded > 0 ? '#ff4d4f' : '#8c8c8c' }}\n        prefix={stats.ungeocoded > 0 ? <WarningOutlined /> : undefined}\n      />\n    </Card>\n  </Col>\n  <Col xs={24} sm={12} md={6}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Average Confidence\"\n        value={stats.confidence.average ?? 0}\n        suffix=\"%\"\n        valueStyle={{\n          color:\n            !stats.confidence.average ? '#8c8c8c'\n            : stats.confidence.average >= 85 ? '#52c41a'\n            : stats.confidence.average >= 60 ? '#faad14'\n            : '#ff4d4f',\n        }}\n      />\n    </Card>\n  </Col>\n</Row>\n

Responsive Grid: - xs={24}: Mobile (full width) - sm={12}: Tablet (2 columns, 50% width each) - md={6}: Desktop (4 columns, 25% width each)

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#confidence-level-cards","title":"Confidence Level Cards","text":"
<Row gutter={[12, 12]} style={{ marginBottom: 24 }}>\n  <Col xs={24} sm={12} md={6}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"High Confidence\"\n        value={stats.confidence.high}\n        prefix={<CheckCircleOutlined />}\n        suffix={<Text type=\"secondary\" style={{ fontSize: 12 }}>\u226585%</Text>}\n        valueStyle={{ color: '#52c41a' }}\n      />\n    </Card>\n  </Col>\n  <Col xs={24} sm={12} md={6}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Medium Confidence\"\n        value={stats.confidence.medium}\n        prefix={<InfoCircleOutlined />}\n        suffix={<Text type=\"secondary\" style={{ fontSize: 12 }}>60-84%</Text>}\n        valueStyle={{ color: '#faad14' }}\n      />\n    </Card>\n  </Col>\n  {/* Low confidence and Manual/None cards... */}\n</Row>\n
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#provider-distribution-cards","title":"Provider Distribution Cards","text":"
<Row gutter={[12, 12]} style={{ marginBottom: 24 }}>\n  {Object.entries(stats.providers).map(([provider, count]) => (\n    <Col xs={24} sm={12} md={6} key={provider}>\n      <Card size=\"small\">\n        <Statistic\n          title={provider.charAt(0).toUpperCase() + provider.slice(1)}\n          value={count}\n          valueStyle={{ fontSize: 18 }}\n        />\n      </Card>\n    </Col>\n  ))}\n</Row>\n

Dynamic Grid: Number of cards adapts to number of providers in response.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#building-type-cards","title":"Building Type Cards","text":"
<Row gutter={[12, 12]}>\n  <Col xs={24} sm={12} md={6}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Single Family\"\n        value={stats.buildingTypes.SINGLE_FAMILY}\n        valueStyle={{ color: '#1890ff' }}\n      />\n    </Card>\n  </Col>\n  {/* Multi-Unit, Mixed Use, Commercial cards... */}\n</Row>\n
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#local-state","title":"Local State","text":"
const [stats, setStats] = useState<LocationStats | null>(null);\nconst [loading, setLoading] = useState(true);\n
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#data-fetching","title":"Data Fetching","text":"
const loadStats = useCallback(async () => {\n  try {\n    const { data } = await api.get<LocationStats>('/map/locations/stats');\n    setStats(data);\n  } catch {\n    message.error('Failed to load data quality stats');\n  } finally {\n    setLoading(false);\n  }\n}, [message]);\n
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#auto-refresh-setup","title":"Auto-Refresh Setup","text":"
useEffect(() => {\n  loadStats();  // Initial load\n  const interval = setInterval(loadStats, 30000);  // Refresh every 30s\n  return () => clearInterval(interval);  // Cleanup on unmount\n}, [loadStats]);\n

Pattern: 1. Load data immediately on mount 2. Set up interval for 30-second refreshes 3. Clean up interval when component unmounts (prevents memory leaks)

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#endpoint-used","title":"Endpoint Used","text":"

GET /map/locations/stats - Fetch location statistics

const { data } = await api.get<LocationStats>('/map/locations/stats');\n

Response:

{\n  \"total\": 15234,\n  \"geocoded\": 14980,\n  \"ungeocoded\": 254,\n  \"confidence\": {\n    \"average\": 87.3,\n    \"high\": 13500,\n    \"medium\": 1200,\n    \"low\": 280,\n    \"none\": 254\n  },\n  \"providers\": {\n    \"nominatim\": 8500,\n    \"arcgis\": 3200,\n    \"photon\": 1800,\n    \"google\": 980,\n    \"manual\": 500\n  },\n  \"buildingTypes\": {\n    \"SINGLE_FAMILY\": 10500,\n    \"MULTI_UNIT\": 3200,\n    \"MIXED_USE\": 1000,\n    \"COMMERCIAL\": 534\n  }\n}\n

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#percentage-calculation","title":"Percentage Calculation","text":"
<Text type=\"secondary\" style={{ fontSize: 12 }}>\n  ({stats.total > 0 ? Math.round((stats.geocoded / stats.total) * 100) : 0}%)\n</Text>\n

Pattern: Round percentage to nearest integer, handle division by zero.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#conditional-color","title":"Conditional Color","text":"
valueStyle={{\n  color:\n    !stats.confidence.average ? '#8c8c8c'\n    : stats.confidence.average >= 85 ? '#52c41a'\n    : stats.confidence.average >= 60 ? '#faad14'\n    : '#ff4d4f',\n}}\n

Color Logic: - No average \u2192 Gray - \u226585% \u2192 Green - 60-84% \u2192 Yellow - <60% \u2192 Red

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#conditional-icon","title":"Conditional Icon","text":"
prefix={stats.ungeocoded > 0 ? <WarningOutlined /> : undefined}\n

Pattern: Show warning icon only if ungeocoded count > 0.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#auto-refresh-pattern","title":"Auto-Refresh Pattern","text":"
useEffect(() => {\n  loadStats();\n  const interval = setInterval(loadStats, 30000);\n  return () => clearInterval(interval);\n}, [loadStats]);\n

Best Practice: Always clean up intervals to prevent memory leaks.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#auto-refresh-cleanup","title":"Auto-Refresh Cleanup","text":"
return () => clearInterval(interval);\n

Why Important: Without cleanup, interval continues running after component unmounts, causing memory leaks and unnecessary API calls.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#usecallback-for-load-function","title":"useCallback for Load Function","text":"
const loadStats = useCallback(async () => { /* ... */ }, [message]);\n

Why: Prevents recreation of load function on every render, essential for stable useEffect dependency.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#efficient-percentage-calculation","title":"Efficient Percentage Calculation","text":"
Math.round((stats.geocoded / stats.total) * 100)\n

Math.round() is more efficient than .toFixed() for integer percentages.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#responsive-grid","title":"Responsive Grid","text":"
<Col xs={24} sm={12} md={6}>\n

Breakpoints: - Mobile (xs, < 576px): 24/24 = 100% width (1 column) - Tablet (sm, \u2265 576px): 12/24 = 50% width (2 columns) - Desktop (md, \u2265 768px): 6/24 = 25% width (4 columns)

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#padding-adjustment","title":"Padding Adjustment","text":"
<div style={{ padding: screens.md ? 24 : 16 }}>\n

Reduces padding on mobile to maximize screen space.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#statistic-titles","title":"Statistic Titles","text":"
<Statistic title=\"Total Locations\" />\n

Clear, descriptive titles for each metric.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#icon-text-combination","title":"Icon + Text Combination","text":"
<Statistic\n  prefix={<EnvironmentOutlined />}\n  value={stats.total}\n/>\n

Icons enhance visual communication but text labels provide meaning.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#suffix-explanations","title":"Suffix Explanations","text":"
<Statistic\n  suffix={<Text type=\"secondary\">\u226585%</Text>}\n/>\n

Explains threshold for confidence levels.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#statistics-not-loading","title":"Statistics Not Loading","text":"

Symptoms: - Loading spinner forever - Error message \"Failed to load data quality stats\"

Causes: 1. API server down 2. Database connection issue 3. Permission denied

Solutions:

# Check API logs\ndocker compose logs -f api | grep locations\n\n# Test endpoint\ncurl -H \"Authorization: Bearer <token>\" \\\n  http://localhost:4000/map/locations/stats\n\n# Check database\ndocker compose exec api npx prisma studio\n# Navigate to Location model, verify records exist\n

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#percentage-shows-0-but-locations-exist","title":"Percentage Shows 0% (but locations exist)","text":"

Cause: All locations ungeocoded (stats.geocoded === 0)

Expected Behavior: Percentage correctly shows 0% (not a bug)

Solution: Geocode locations using LocationsPage bulk geocoding feature.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#average-confidence-shows-0","title":"Average Confidence Shows 0%","text":"

Cause: No geocoded locations have confidence scores

Expected Behavior: Shows 0% and gray color

Solution: Confidence scores only populated during geocoding. Re-geocode locations to populate confidence.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#auto-refresh-not-working","title":"Auto-Refresh Not Working","text":"

Symptoms: - Statistics never update automatically - Must manually click Refresh button

Causes: 1. Component unmounting and remounting (React Strict Mode in dev) 2. Interval cleared prematurely 3. Browser tab inactive (browser throttles timers)

Debug:

useEffect(() => {\n  console.log('Setting up auto-refresh');\n  loadStats();\n  const interval = setInterval(() => {\n    console.log('Auto-refreshing...');\n    loadStats();\n  }, 30000);\n  return () => {\n    console.log('Cleaning up interval');\n    clearInterval(interval);\n  };\n}, [loadStats]);\n

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#building-types-show-unexpected-zeros","title":"Building Types Show Unexpected Zeros","text":"

Cause: Building type not set for locations (optional field)

Expected Behavior: buildingTypes counts only locations with buildingType set

Solution: Update locations to set buildingType field (use LocationsPage).

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#backend-integration","title":"Backend Integration","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#frontend-pages","title":"Frontend Pages","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#features_1","title":"Features","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#user-guides","title":"User Guides","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#troubleshooting_1","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#related-pages","title":"Related Pages","text":""},{"location":"v2/frontend/pages/admin/docs-page/","title":"DocsPage","text":""},{"location":"v2/frontend/pages/admin/docs-page/#overview","title":"Overview","text":"

File: admin/src/pages/DocsPage.tsx Route: /app/docs Role Requirements: SUPER_ADMIN

DocsPage is a comprehensive documentation editor for Changemaker Lite's MkDocs documentation system. It provides a full-featured IDE-like experience with a file tree browser, Monaco code editor with syntax highlighting, live MkDocs preview, and an extensive MkDocs snippet system with 60+ predefined templates for formatting, headings, admonitions, code blocks, and content insertion.

The page offers three layout modes (split, editor-only, preview-only), collapsible file tree with search/filter, drag-to-resize panels, keyboard shortcuts (Ctrl+S to save), CRUD operations for files/folders, and a rich formatting toolbar with dropdown menus for quick content insertion.

Key Features: - Obsidian-style tight file tree with smooth hover effects - Monaco Editor with Markdown syntax highlighting - Split-pane layout with draggable dividers (tree, editor, preview) - Live MkDocs preview in iframe with auto-refresh on save - Custom right-click context menu with hierarchical snippet groups - Formatting toolbar with Bold, Italic, Strikethrough, Highlight, etc. - 60+ MkDocs snippets (formatting, headings, admonitions, code, insert elements) - File/folder creation, renaming, deletion via tree context menu - Filter/search across file tree with auto-expand - URL preview bar (production + localhost links above iframe) - MkDocs site building (SUPER_ADMIN only)

Key Components: - Ant Design Tree for file browser - Monaco Editor (@monaco-editor/react) for code editing - Custom snippet system with wrap/block/insert types - Keyboard bindings (Ctrl+B for bold, Ctrl+I for italic, Ctrl+S for save) - Three-panel resizable layout with localStorage persistence

"},{"location":"v2/frontend/pages/admin/docs-page/#screenshot","title":"Screenshot","text":"

[Screenshot: DocsPage showing three-panel layout: left sidebar with file tree (Obsidian-style, collapsible, filterable), center Monaco editor with dark theme and formatting toolbar above, right iframe showing live MkDocs preview with URL bar at top displaying production/localhost links. Top toolbar has layout mode buttons (Split/Editor/Preview), Save button, Refresh Preview, Open MkDocs, and Build buttons.]

"},{"location":"v2/frontend/pages/admin/docs-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/docs-page/#core-features","title":"Core Features","text":"
  1. Three-Panel Resizable Layout
  2. Left: File tree (160px - 400px width, draggable divider)
  3. Center: Monaco editor (40% width default, draggable)
  4. Right: MkDocs preview iframe (60% width default, draggable)
  5. Layout mode switcher: Split / Editor-only / Preview-only
  6. Collapsible tree panel (click hamburger or thin bar to toggle)
  7. Persists layout preferences in localStorage

  8. File Tree Browser

  9. Hierarchical file/folder display (Ant Design Tree)
  10. Shows .md files without extension (e.g., \"index\" not \"index.md\")
  11. Tight spacing (28px row height) for compact view
  12. Smooth hover effects (rgba background transitions)
  13. Selected file highlighted
  14. Expand/collapse folders with arrow icons
  15. Context menu (right-click) for New File, New Folder, Rename, Delete
  16. Search/filter with auto-expand matching nodes

  17. Monaco Code Editor

  18. Markdown syntax highlighting with VS Dark theme
  19. Line numbers, word wrap, no minimap (clean editing)
  20. Real-time change detection (dirty state tracking)
  21. Ctrl+S keyboard shortcut for saving
  22. Custom right-click context menu (replaces Monaco's default)
  23. Detects file type (markdown, yaml, json, css, html, javascript)

  24. MkDocs Snippet System (60+ snippets)

  25. Formatting: Bold (**), Italic (*), Strikethrough (~~), Highlight (==), Inline Code (`), Keyboard Key (++)
  26. Headings: H1-H4 with # syntax
  27. Admonitions: Note, Warning, Tip, Danger, Info, Success, Question, Abstract, Example, Bug, Quote (+ collapsible variants)
  28. Code: Code block (```), Annotated code, Mermaid diagrams
  29. Insert: Link, Image, Button, Primary button, Material icon, Table, Task list, Tabs, Math block, Footnote, Definition list, Horizontal rule
  30. Snippet types: wrap (surround selection), block (insert template), insert (paste content)

  31. Formatting Toolbar

  32. Always visible for .md files (28px height, compact)
  33. Direct buttons: Bold, Italic, Strikethrough, Highlight, Inline Code, Keyboard Key
  34. Dropdown menus: Headings (H1-H4), Admonitions (11 types + collapsible), Code (3 types), Insert (12 elements)
  35. Keyboard shortcuts shown in menus (Ctrl+B, Ctrl+I)

  36. Live Preview

  37. MkDocs server iframe (proxied via /mkdocs-proxy/)
  38. Auto-reload on save (500ms delay)
  39. Manual refresh button in toolbar
  40. URL preview bar above iframe showing production + localhost URLs
  41. Click URL buttons to open in new tab

  42. File Operations

  43. New File: Right-click folder \u2192 New File (auto-appends .md)
  44. New Folder: Right-click folder \u2192 New Folder
  45. Rename: Right-click file/folder \u2192 Rename
  46. Delete: Right-click file/folder \u2192 Delete (with confirmation modal)
  47. Root-level creation: Toolbar buttons (+ File, + Folder icons)

  48. File Tree Actions

  49. Filter: Search button in tree toolbar \u2192 input field \u2192 auto-expand matches
  50. Expand All: Button in tree toolbar
  51. Collapse All: Button in tree toolbar
  52. Hide Panel: Fold icon collapses tree to thin bar
  53. Show Panel: Click thin bar or unfold icon to restore tree

  54. Save Operations

  55. Save button in top toolbar (blue primary, shows when dirty)
  56. Ctrl+S keyboard shortcut (global)
  57. Loading state during save
  58. Success message on save
  59. Modified indicator in editor status bar

  60. Layout Modes

  61. MkDocs Site Building (SUPER_ADMIN)

  62. Mobile Detection

"},{"location":"v2/frontend/pages/admin/docs-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/docs-page/#opening-editor","title":"Opening Editor","text":"
  1. Navigate to page: Admin sidebar \u2192 System \u2192 Documentation
  2. Page loads: File tree appears on left, empty editor in center, MkDocs homepage in preview
  3. Select file: Click on file in tree (e.g., index.md)
  4. Editor loads: Monaco editor shows file content, preview updates to matching page
"},{"location":"v2/frontend/pages/admin/docs-page/#editing-documentation","title":"Editing Documentation","text":"
  1. Modify content: Type in Monaco editor (Markdown syntax)
  2. Use formatting toolbar: Click Bold/Italic/etc. buttons or dropdown menus
  3. Insert snippets: Click Insert dropdown \u2192 select Link/Image/Table/etc.
  4. Check preview: Right pane shows live rendering
  5. Save changes: Click Save button or press Ctrl+S
  6. Auto-refresh: Preview reloads after 500ms delay
"},{"location":"v2/frontend/pages/admin/docs-page/#using-formatting-toolbar","title":"Using Formatting Toolbar","text":"

Direct Buttons (wrap selected text): 1. Bold: Select text, click B button (or Ctrl+B) \u2192 **text** 2. Italic: Select text, click I button (or Ctrl+I) \u2192 *text* 3. Strikethrough: Select text, click S\u0336 button \u2192 ~~text~~ 4. Highlight: Select text, click highlight button \u2192 ==text== 5. Inline Code: Select text, click <> button \u2192 `text` 6. Keyboard Key: Select text, click K button \u2192 ++text++

Dropdown Menus (insert templates): 1. Headings: Click \"H \u25bc\" \u2192 select H1/H2/H3/H4 \u2192 inserts ## at cursor 2. Admonitions: Click \"Admonitions \u25bc\" \u2192 select Note/Warning/etc. \u2192 inserts block:

!!! note \"Title\"\n    Content here\n
3. Code: Click \"Code \u25bc\" \u2192 select Code Block/Annotated/Mermaid \u2192 inserts template 4. Insert: Click \"Insert \u25bc\" \u2192 select Link/Image/Table/etc. \u2192 pastes element

"},{"location":"v2/frontend/pages/admin/docs-page/#using-right-click-context-menu","title":"Using Right-Click Context Menu","text":"
  1. Right-click in editor: Custom context menu appears (not Monaco's default)
  2. Select category: Formatting, Headings, Admonitions, Code, or Insert submenu
  3. Click snippet: Snippet applied to cursor/selection
  4. Context menu closes: Focus returns to editor

Menu Structure: - Formatting (submenu) \u2192 Bold (Ctrl+B), Italic (Ctrl+I), Strikethrough, etc. - Headings (submenu) \u2192 H1, H2, H3, H4 - Admonitions (submenu) \u2192 Note, Warning, Tip, etc. (13 types) - Code (submenu) \u2192 Code Block, Annotated Code, Mermaid Diagram - Insert (submenu) \u2192 Link, Image, Button, Icon, Table, etc. (12 elements)

"},{"location":"v2/frontend/pages/admin/docs-page/#managing-files","title":"Managing Files","text":"

Creating New File: 1. Right-click folder in tree: Context menu appears 2. Click \"New File\": Modal opens with input 3. Enter name: Type filename (e.g., my-page) 4. Submit: Modal closes, new file appears in tree (auto-appends .md) 5. File auto-opens: Editor loads with template content (# {filename})

Creating New Folder: 1. Right-click folder in tree: Context menu appears 2. Click \"New Folder\": Modal opens 3. Enter name: Type folder name 4. Submit: Modal closes, new folder appears in tree

Renaming File/Folder: 1. Right-click file/folder: Context menu appears 2. Click \"Rename\": Modal opens with current name 3. Edit name: Modify name 4. Submit: Modal closes, tree updates

Deleting File/Folder: 1. Right-click file/folder: Context menu appears 2. Click \"Delete\": Confirmation modal appears 3. Confirm: Click OK 4. File removed: Tree refreshes, if currently open file deleted, editor clears

Root-Level Creation: 1. Click \"+ File\" or \"+ Folder\" icons in tree toolbar 2. Follow same modal flow as folder context menu

"},{"location":"v2/frontend/pages/admin/docs-page/#filtering-file-tree","title":"Filtering File Tree","text":"
  1. Click search icon in tree toolbar: Filter input appears below toolbar
  2. Type query: Enter filename or partial match (e.g., \"api\")
  3. Tree filters: Only matching files/folders shown
  4. Matching folders auto-expand: See nested matches
  5. Clear filter: Click X in input or search icon to hide input
"},{"location":"v2/frontend/pages/admin/docs-page/#resizing-panels","title":"Resizing Panels","text":"

Tree Panel Resize: 1. Hover over tree divider: Vertical bar (1px) between tree and editor 2. Divider highlights: Changes color to primary 3. Drag left/right: Tree width adjusts (160px - 400px range) 4. Release: Width persists in localStorage

Editor/Preview Split: 1. Hover over editor/preview divider: Vertical bar (4px) between panes 2. Divider highlights: Changes color to primary 3. Drag left/right: Adjust split percentage (15% - 85% range) 4. Release: Split persists in localStorage

"},{"location":"v2/frontend/pages/admin/docs-page/#switching-layout-modes","title":"Switching Layout Modes","text":"
  1. Click layout mode button in toolbar:
  2. Split icon: Editor + Preview side-by-side
  3. Code icon: Editor only (full width)
  4. Eye icon: Preview only (full width)
  5. Layout changes immediately
  6. Active mode highlighted: Primary blue color
  7. Preference saved: Persists in localStorage
"},{"location":"v2/frontend/pages/admin/docs-page/#opening-preview-urls","title":"Opening Preview URLs","text":"
  1. Check URL bar above preview iframe (only for .md files)
  2. Click \"Production\" button: Opens https://docs.cmlite.org/{path} in new tab
  3. Click \"Localhost\" button: Opens http://localhost:4003/{path} in new tab
"},{"location":"v2/frontend/pages/admin/docs-page/#building-mkdocs-site-super_admin","title":"Building MkDocs Site (SUPER_ADMIN)","text":"
  1. Click Build button in toolbar (hammer icon)
  2. Confirmation modal: \"Build static site? This may take a few minutes.\"
  3. Confirm: Click OK
  4. Build starts: Button shows loading spinner
  5. Wait: ~30-60 seconds for build to complete
  6. Success message: \"Site built successfully\"
  7. Check output: Navigate to MkDocs site URL to verify
"},{"location":"v2/frontend/pages/admin/docs-page/#saving-and-preview-refresh","title":"Saving and Preview Refresh","text":"
  1. Make changes in editor
  2. Status bar shows \"Modified\" in yellow
  3. Save with Ctrl+S or Save button
  4. Success message: \"Saved\"
  5. Preview auto-refreshes: After 500ms delay
  6. Status bar clears \"Modified\"
"},{"location":"v2/frontend/pages/admin/docs-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/docs-page/#top-toolbar","title":"Top Toolbar","text":"
<Space size={8}>\n  <Tooltip title=\"Editor + Preview\">\n    <Button\n      type={layout === 'split' ? 'primary' : 'text'}\n      icon={<ColumnWidthOutlined />}\n      onClick={() => setLayout('split')}\n    />\n  </Tooltip>\n  <Tooltip title=\"Editor Only\">\n    <Button\n      type={layout === 'editor' ? 'primary' : 'text'}\n      icon={<CodeOutlined />}\n      onClick={() => setLayout('editor')}\n    />\n  </Tooltip>\n  <Tooltip title=\"Preview Only\">\n    <Button\n      type={layout === 'preview' ? 'primary' : 'text'}\n      icon={<EyeOutlined />}\n      onClick={() => setLayout('preview')}\n    />\n  </Tooltip>\n\n  {dirty && (\n    <Button type=\"primary\" icon={<SaveOutlined />} onClick={saveFile} loading={saving}>\n      Save\n    </Button>\n  )}\n\n  <Button type=\"text\" icon={<ReloadOutlined />} onClick={refreshPreview} />\n  <Button type=\"text\" icon={<ExportOutlined />} onClick={() => window.open(mkdocsDirectUrl, '_blank')} />\n\n  {isSuperAdmin && (\n    <Button type=\"text\" icon={<BuildOutlined />} onClick={confirmAndBuild} loading={building} />\n  )}\n</Space>\n
"},{"location":"v2/frontend/pages/admin/docs-page/#file-tree-component","title":"File Tree Component","text":"
<Tree\n  treeData={treeData}\n  showIcon={false}\n  showLine={false}\n  selectedKeys={selectedFile ? [selectedFile] : []}\n  expandedKeys={expandedKeys}\n  onExpand={(keys) => setExpandedKeys(keys)}\n  onSelect={(keys) => {\n    if (keys.length === 0) return;\n    const path = keys[0] as string;\n    if (isDirectoryPath(path)) return;\n    onTreeSelect(keys);\n  }}\n  blockNode\n  titleRender={(nodeData) => {\n    const nodePath = nodeData.key as string;\n    const isDir = isDirectoryPath(nodePath);\n    return (\n      <Dropdown\n        menu={{ items: getContextMenuItems(nodePath, isDir) }}\n        trigger={['contextMenu']}\n      >\n        <span>{nodeData.title as string}</span>\n      </Dropdown>\n    );\n  }}\n/>\n

Tree Styling (Obsidian-style):

.docs-tree .ant-tree-treenode {\n  padding: 0 !important;\n  min-height: 28px !important;\n  line-height: 28px !important;\n  border-radius: 0 !important;\n  transition: background 0.15s !important;\n}\n.docs-tree .ant-tree-treenode:hover {\n  background: rgba(255,255,255,0.06) !important;\n}\n.docs-tree .ant-tree-treenode-selected {\n  background: rgba(255,255,255,0.10) !important;\n}\n

"},{"location":"v2/frontend/pages/admin/docs-page/#monaco-editor","title":"Monaco Editor","text":"
<Editor\n  language={selectedFile.endsWith('.md') ? 'markdown' : 'yaml'}\n  theme=\"vs-dark\"\n  value={fileContent}\n  onChange={onEditorChange}\n  onMount={handleEditorMount}\n  options={{\n    minimap: { enabled: false },\n    contextmenu: false,  // Disable default context menu\n    wordWrap: 'on',\n    lineNumbers: 'on',\n    fontSize: 14,\n    scrollBeyondLastLine: false,\n    automaticLayout: true,\n    tabSize: 2,\n  }}\n/>\n
"},{"location":"v2/frontend/pages/admin/docs-page/#formatting-toolbar","title":"Formatting Toolbar","text":"
<div style={{ height: 28, display: 'flex', alignItems: 'center', gap: 2 }}>\n  {/* Direct buttons */}\n  <Tooltip title=\"Bold (Ctrl+B)\">\n    <Button type=\"text\" size=\"small\" icon={<BoldOutlined />} onClick={() => handleToolbarSnippet('bold')} />\n  </Tooltip>\n  <Tooltip title=\"Italic (Ctrl+I)\">\n    <Button type=\"text\" size=\"small\" icon={<ItalicOutlined />} onClick={() => handleToolbarSnippet('italic')} />\n  </Tooltip>\n  {/* ... more buttons ... */}\n\n  {/* Dropdown menus */}\n  <Dropdown menu={{ items: headingItems }} trigger={['click']}>\n    <Button type=\"text\" size=\"small\">\n      <FontSizeOutlined /> H <DownOutlined />\n    </Button>\n  </Dropdown>\n\n  <Dropdown menu={{ items: admonitionItems }} trigger={['click']}>\n    <Button type=\"text\" size=\"small\">\n      <AlertOutlined /> Admonitions <DownOutlined />\n    </Button>\n  </Dropdown>\n  {/* ... more dropdowns ... */}\n</div>\n
"},{"location":"v2/frontend/pages/admin/docs-page/#url-preview-bar","title":"URL Preview Bar","text":"
const URLPreviewBar = ({ filePath }: { filePath: string | null }) => {\n  if (!filePath || !filePath.endsWith('.md')) return null;\n\n  const productionUrl = `https://docs.cmlite.org/${urlPath}/`;\n  const localhostUrl = `http://localhost:4003/${urlPath}/`;\n\n  return (\n    <div style={{ height: 32, display: 'flex', alignItems: 'center', gap: 8 }}>\n      <Typography.Text>Preview:</Typography.Text>\n      <Button size=\"small\" icon={<ExportOutlined />} onClick={() => openUrl(productionUrl)}>\n        Production\n      </Button>\n      <Button size=\"small\" icon={<ExportOutlined />} onClick={() => openUrl(localhostUrl)}>\n        Localhost\n      </Button>\n    </div>\n  );\n};\n
"},{"location":"v2/frontend/pages/admin/docs-page/#snippet-system","title":"Snippet System","text":"

Snippet Definition:

interface MkDocsSnippet {\n  id: string;\n  label: string;\n  group: 'formatting' | 'heading' | 'admonition' | 'code' | 'insert';\n  type: 'wrap' | 'block' | 'insert';\n  prefix?: string;\n  suffix?: string;\n  template?: string;\n  keybinding?: 'ctrl+b' | 'ctrl+i';\n}\n\nconst SNIPPETS: MkDocsSnippet[] = [\n  { id: 'bold', label: 'Bold', group: 'formatting', type: 'wrap', prefix: '**', suffix: '**', keybinding: 'ctrl+b' },\n  { id: 'italic', label: 'Italic', group: 'formatting', type: 'wrap', prefix: '*', suffix: '*', keybinding: 'ctrl+i' },\n  { id: 'h2', label: 'Heading 2', group: 'heading', type: 'block', template: '## $CURSOR' },\n  { id: 'admonition-note', label: 'Note', group: 'admonition', type: 'block', template: '!!! note \"Title\"\\n    Content here' },\n  { id: 'code-block', label: 'Code Block', group: 'code', type: 'block', template: '```python\\n$CURSOR\\n```' },\n  { id: 'link', label: 'Link', group: 'insert', type: 'wrap', prefix: '[', suffix: '](url)' },\n  { id: 'image', label: 'Image', group: 'insert', type: 'insert', template: '![Alt text](image.png)' },\n  // ... 60+ total snippets\n];\n

Apply Snippet Function:

function applySnippet(\n  ed: monacoEditor.IStandaloneCodeEditor,\n  snippet: MkDocsSnippet,\n  monaco: typeof import('monaco-editor'),\n) {\n  const sel = ed.getSelection();\n  const model = ed.getModel();\n  if (!sel || !model) return;\n\n  const selectedText = model.getValueInRange(sel);\n\n  if (snippet.type === 'wrap' && snippet.prefix != null && snippet.suffix != null) {\n    if (selectedText) {\n      ed.executeEdits('mkdocs-snippet', [{\n        range: sel,\n        text: snippet.prefix + selectedText + snippet.suffix,\n      }]);\n    } else {\n      const placeholder = 'text';\n      ed.executeEdits('mkdocs-snippet', [{\n        range: sel,\n        text: snippet.prefix + placeholder + snippet.suffix,\n      }]);\n      // Select placeholder\n      const pos = sel.getStartPosition();\n      const startCol = pos.column + snippet.prefix.length;\n      ed.setSelection(new monaco.Selection(pos.lineNumber, startCol, pos.lineNumber, startCol + placeholder.length));\n    }\n  } else if (snippet.type === 'block' && snippet.template) {\n    let text = snippet.template.replace('$CURSOR', selectedText);\n    ed.executeEdits('mkdocs-snippet', [{ range: sel, text }]);\n  } else if (snippet.type === 'insert' && snippet.template) {\n    ed.executeEdits('mkdocs-snippet', [{ range: sel, text: snippet.template }]);\n  }\n\n  ed.focus();\n}\n

"},{"location":"v2/frontend/pages/admin/docs-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/docs-page/#local-state","title":"Local State","text":"

File Tree & Content:

const [fileTree, setFileTree] = useState<FileNode[]>([]);\nconst [selectedFile, setSelectedFile] = useState<string | null>(null);\nconst [fileContent, setFileContent] = useState<string>('');\nconst [originalContent, setOriginalContent] = useState<string>('');\nconst [dirty, setDirty] = useState(false);\n

UI State:

const [loading, setLoading] = useState(true);\nconst [saving, setSaving] = useState(false);\nconst [fileLoading, setFileLoading] = useState(false);\nconst [layout, setLayout] = useState<LayoutMode>(() => (localStorage.getItem(LAYOUT_STORAGE_KEY) as LayoutMode) || 'split');\nconst [splitPercent, setSplitPercent] = useState<number>(() => Number(localStorage.getItem(DIVIDER_STORAGE_KEY)) || 50);\nconst [treeCollapsed, setTreeCollapsed] = useState<boolean>(() => localStorage.getItem(TREE_COLLAPSED_KEY) === 'true');\nconst [treeWidth, setTreeWidth] = useState<number>(() => Number(localStorage.getItem(TREE_WIDTH_KEY)) || 200);\n

Filter & Modal State:

const [filterQuery, setFilterQuery] = useState('');\nconst [filterVisible, setFilterVisible] = useState(false);\nconst [modalType, setModalType] = useState<'newFile' | 'newFolder' | 'rename' | null>(null);\nconst [modalInput, setModalInput] = useState('');\nconst [contextPath, setContextPath] = useState<string>('');\n

Monaco Refs:

const monacoEditorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null);\nconst monacoRef = useRef<typeof import('monaco-editor') | null>(null);\nconst previewIframeRef = useRef<HTMLIFrameElement>(null);\n

"},{"location":"v2/frontend/pages/admin/docs-page/#localstorage-persistence","title":"localStorage Persistence","text":"
useEffect(() => { localStorage.setItem(LAYOUT_STORAGE_KEY, layout); }, [layout]);\nuseEffect(() => { localStorage.setItem(DIVIDER_STORAGE_KEY, String(splitPercent)); }, [splitPercent]);\nuseEffect(() => { localStorage.setItem(TREE_COLLAPSED_KEY, String(treeCollapsed)); }, [treeCollapsed]);\nuseEffect(() => { localStorage.setItem(TREE_WIDTH_KEY, String(treeWidth)); }, [treeWidth]);\n
"},{"location":"v2/frontend/pages/admin/docs-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/docs-page/#endpoints-used","title":"Endpoints Used","text":"

GET /docs/files - Fetch file tree

const { data } = await api.get<FileNode[]>('/docs/files');\n

Response:

[\n  {\n    \"name\": \"index.md\",\n    \"path\": \"index.md\",\n    \"isDirectory\": false\n  },\n  {\n    \"name\": \"v2\",\n    \"path\": \"v2\",\n    \"isDirectory\": true,\n    \"children\": [\n      {\n        \"name\": \"index.md\",\n        \"path\": \"v2/index.md\",\n        \"isDirectory\": false\n      }\n    ]\n  }\n]\n

GET /docs/files/:filePath - Read file content

const { data } = await api.get<{ path: string; content: string }>(`/docs/files/${filePath}`);\n

Response:

{\n  \"path\": \"v2/index.md\",\n  \"content\": \"# V2 Documentation\\n\\nWelcome to V2 docs...\"\n}\n

PUT /docs/files/:filePath - Update file

await api.put(`/docs/files/${filePath}`, { content: fileContent });\n

POST /docs/files/:filePath - Create file/folder

// Create file\nawait api.post(`/docs/files/${path}`, { content: '# New File\\n' });\n\n// Create folder\nawait api.post(`/docs/files/${path}`, { isDirectory: true });\n

POST /docs/files/rename - Rename file/folder

await api.post('/docs/files/rename', { from: 'old-path.md', to: 'new-path.md' });\n

DELETE /docs/files/:filePath - Delete file/folder

await api.delete(`/docs/files/${filePath}`);\n

"},{"location":"v2/frontend/pages/admin/docs-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/docs-page/#keyboard-shortcut-registration","title":"Keyboard Shortcut Registration","text":"
const handleEditorMount: OnMount = useCallback((ed, monaco) => {\n  monacoEditorRef.current = ed;\n  monacoRef.current = monaco;\n\n  // Register Ctrl+B and Ctrl+I\n  SNIPPETS.filter(s => s.keybinding).forEach(snippet => {\n    const kb = snippet.keybinding === 'ctrl+b'\n      ? monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyB\n      : monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyI;\n    ed.addAction({\n      id: `mkdocs.${snippet.id}`,\n      label: snippet.label,\n      keybindings: [kb],\n      run: (editor) => applySnippet(editor as monacoEditor.IStandaloneCodeEditor, snippet, monaco),\n    });\n  });\n}, []);\n
"},{"location":"v2/frontend/pages/admin/docs-page/#drag-to-resize-logic","title":"Drag-to-Resize Logic","text":"
const onTreeDividerDown = useCallback(() => {\n  dragging.current = 'tree';\n  document.body.style.cursor = 'col-resize';\n  document.body.style.userSelect = 'none';\n}, []);\n\nuseEffect(() => {\n  const onMouseMove = (e: MouseEvent) => {\n    if (!dragging.current || !containerRef.current) return;\n    const rect = containerRef.current.getBoundingClientRect();\n\n    if (dragging.current === 'tree') {\n      const w = e.clientX - rect.left;\n      setTreeWidth(Math.min(MAX_TREE_WIDTH, Math.max(MIN_TREE_WIDTH, w)));\n    }\n  };\n\n  const onMouseUp = () => {\n    if (dragging.current) {\n      dragging.current = false;\n      document.body.style.cursor = '';\n      document.body.style.userSelect = '';\n    }\n  };\n\n  window.addEventListener('mousemove', onMouseMove);\n  window.addEventListener('mouseup', onMouseUp);\n  return () => {\n    window.removeEventListener('mousemove', onMouseMove);\n    window.removeEventListener('mouseup', onMouseUp);\n  };\n}, []);\n
"},{"location":"v2/frontend/pages/admin/docs-page/#file-tree-filtering","title":"File Tree Filtering","text":"
function filterTree(nodes: FileNode[], query: string): FileNode[] {\n  const q = query.toLowerCase();\n  const filtered: FileNode[] = [];\n\n  for (const node of nodes) {\n    if (node.isDirectory) {\n      const childMatches = node.children ? filterTree(node.children, query) : [];\n      if (childMatches.length > 0 || node.name.toLowerCase().includes(q)) {\n        filtered.push({ ...node, children: childMatches.length > 0 ? childMatches : node.children });\n      }\n    } else {\n      if (node.name.toLowerCase().includes(q)) {\n        filtered.push(node);\n      }\n    }\n  }\n\n  return filtered;\n}\n\n// Auto-expand when filtering\nconst expandedKeysForFilter = useMemo(() => {\n  if (filterQuery.trim()) return collectAllDirKeys(filteredTree);\n  return [];\n}, [filterQuery, filteredTree]);\n
"},{"location":"v2/frontend/pages/admin/docs-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/docs-page/#monaco-editor-lazy-load","title":"Monaco Editor Lazy Load","text":"

Monaco loads from CDN when component mounts (not in main bundle).

"},{"location":"v2/frontend/pages/admin/docs-page/#usecallback-for-event-handlers","title":"useCallback for Event Handlers","text":"

All drag handlers, save handler, and snippet handler use useCallback to prevent recreation.

"},{"location":"v2/frontend/pages/admin/docs-page/#conditional-toolbar-rendering","title":"Conditional Toolbar Rendering","text":"
{selectedFile?.endsWith('.md') && !fileLoading && (\n  <div>{/* Formatting toolbar */}</div>\n)}\n

Only renders toolbar for Markdown files.

"},{"location":"v2/frontend/pages/admin/docs-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/docs-page/#mobile-warning","title":"Mobile Warning","text":"
if (isMobile) {\n  return <Result status=\"info\" title=\"Desktop Required\" />;\n}\n

Screens < 768px: Show warning, don't render editor.

"},{"location":"v2/frontend/pages/admin/docs-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/docs-page/#keyboard-shortcuts","title":"Keyboard Shortcuts","text":""},{"location":"v2/frontend/pages/admin/docs-page/#button-labels","title":"Button Labels","text":"

All toolbar buttons have tooltips with keyboard shortcuts shown.

"},{"location":"v2/frontend/pages/admin/docs-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/docs-page/#monaco-editor-blank","title":"Monaco Editor Blank","text":"

Cause: CDN load failed or height not set

Solution:

<Editor height=\"100%\" /> // Parent must have defined height\n

"},{"location":"v2/frontend/pages/admin/docs-page/#preview-not-updating","title":"Preview Not Updating","text":"

Cause: Iframe src not changing or MkDocs server down

Debug:

# Check MkDocs container\ndocker compose logs mkdocs\n\n# Restart MkDocs\ndocker compose restart mkdocs\n

"},{"location":"v2/frontend/pages/admin/docs-page/#file-tree-not-loading","title":"File Tree Not Loading","text":"

Cause: API endpoint failing

Debug:

# Check API logs\ndocker compose logs -f api | grep docs\n\n# Test endpoint\ncurl -H \"Authorization: Bearer <token>\" \\\n  http://localhost:4000/docs/files\n

"},{"location":"v2/frontend/pages/admin/docs-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/docs-page/#backend-integration","title":"Backend Integration","text":""},{"location":"v2/frontend/pages/admin/docs-page/#features_1","title":"Features","text":""},{"location":"v2/frontend/pages/admin/docs-page/#user-guides","title":"User Guides","text":""},{"location":"v2/frontend/pages/admin/docs-page/#external-resources","title":"External Resources","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/","title":"EmailQueuePage","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#overview","title":"Overview","text":"

The EmailQueuePage provides real-time monitoring of the BullMQ email job queue that handles asynchronous advocacy email sending for the Influence module. It displays four key statistics (waiting, active, completed, failed jobs) with auto-refresh functionality and provides administrative controls to pause/resume the queue and clean up old completed jobs. The page is designed for monitoring email delivery health and troubleshooting stuck jobs.

Route: /app/influence/email-queue Component: admin/src/pages/EmailQueuePage.tsx (140 lines) Auth Required: Yes (SUPER_ADMIN or INFLUENCE_ADMIN role recommended) Layout: AppLayout Backend Module: api/src/modules/influence/email-queue/

"},{"location":"v2/frontend/pages/admin/email-queue-page/#screenshot","title":"Screenshot","text":"

[Screenshot: EmailQueuePage with \"Email Queue\" header showing \"RUNNING\" green tag. Right side has three buttons: \"Refresh\", \"Pause\", and \"Clean Old Jobs\". Below are four statistics cards in a row: \"Waiting: 23\" (blue text), \"Active: 2\" (green text), \"Completed: 1,487\" (default gray), and \"Failed: 12\" (red text). The page has minimal UI, focusing on the statistics cards.]

"},{"location":"v2/frontend/pages/admin/email-queue-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#monitoring-email-queue-health","title":"Monitoring Email Queue Health","text":"
  1. Navigate to /app/influence/email-queue
  2. Page loads with initial statistics fetch
  3. Observe statistics cards (displayed in single row):
  4. Waiting: Jobs queued but not yet processing (blue text)
  5. Active: Jobs currently being processed (green text)
  6. Completed: Successfully sent emails (gray text)
  7. Failed: Jobs that encountered errors (red text)
  8. Check queue status tag in header:
  9. RUNNING (green): Queue is processing jobs normally
  10. PAUSED (orange): Queue is stopped, no jobs being processed
  11. Auto-refresh occurs every 10 seconds:
  12. Statistics update silently (no loading spinner)
  13. Numbers increment/decrement based on queue activity
  14. Status tag updates if queue state changes
"},{"location":"v2/frontend/pages/admin/email-queue-page/#pausing-the-email-queue","title":"Pausing the Email Queue","text":"

When to Pause: - Troubleshooting SMTP connection issues - Performing backend maintenance - Preventing emails from sending during off-hours - Testing email configuration changes

Steps:

  1. Click \"Pause\" button in header (next to Refresh button)
  2. API request sent to /api/email-queue/pause
  3. Success message: \"Queue paused\"
  4. Queue status tag changes from \"RUNNING\" (green) to \"PAUSED\" (orange)
  5. Active count drops to 0 (currently processing jobs complete)
  6. Waiting count remains (jobs queued but not processing)
  7. Button label changes to \"Resume\"

Effect: - No new jobs will be picked up from queue - Currently active jobs will complete (cannot interrupt mid-send) - New campaign emails will still be added to queue (just not processed)

"},{"location":"v2/frontend/pages/admin/email-queue-page/#resuming-the-email-queue","title":"Resuming the Email Queue","text":"
  1. Verify SMTP configuration is correct (Settings page)
  2. Click \"Resume\" button in header
  3. API request sent to /api/email-queue/resume
  4. Success message: \"Queue resumed\"
  5. Queue status tag changes from \"PAUSED\" (orange) to \"RUNNING\" (green)
  6. Waiting count begins decreasing as jobs are picked up
  7. Active count increases (workers processing jobs)
  8. Button label changes back to \"Pause\"
"},{"location":"v2/frontend/pages/admin/email-queue-page/#cleaning-old-completed-jobs","title":"Cleaning Old Completed Jobs","text":"

When to Clean: - Completed job count exceeds 10,000 (memory usage) - Queue dashboard feels sluggish - Regular maintenance (weekly/monthly)

Steps:

  1. Click \"Clean Old Jobs\" button in header
  2. Confirmation: No confirmation dialog (immediate action)
  3. API request sent to /api/email-queue/clean
  4. Backend deletes all jobs with status = COMPLETED
  5. Success message: \"Cleaned 1,487 completed jobs\"
  6. Completed count resets to 0
  7. Statistics automatically refresh

Important: This only removes completed jobs. Waiting, active, and failed jobs are preserved.

"},{"location":"v2/frontend/pages/admin/email-queue-page/#refreshing-statistics-manually","title":"Refreshing Statistics Manually","text":"
  1. Click \"Refresh\" button in header (circular arrow icon)
  2. Loading spinner appears on button
  3. API request sent to /api/email-queue/stats
  4. All four statistics update simultaneously
  5. Loading spinner disappears
  6. Use case: Immediate update without waiting for 10-second auto-refresh
"},{"location":"v2/frontend/pages/admin/email-queue-page/#investigating-failed-jobs","title":"Investigating Failed Jobs","text":"

Problem: \"Failed\" count increases (e.g., from 5 to 12)

Diagnosis Steps:

  1. Note current failed count (e.g., 12)
  2. Navigate to backend logs: docker compose logs -f api | grep \"Email job failed\"
  3. Look for error messages:
    Email job failed for campaign abc123: SMTP connection timeout\n
  4. Identify root cause:
  5. SMTP server down
  6. Invalid credentials
  7. Rate limiting
  8. Network connectivity issues

Resolution:

  1. Fix underlying issue (e.g., update SMTP credentials in Settings)
  2. Return to Email Queue page
  3. Consider options:
  4. Retry failed jobs: Currently no UI button (requires backend job retry API)
  5. Clean failed jobs: Click \"Clean Old Jobs\" to remove (also removes completed)
  6. Wait for auto-retry: BullMQ will retry failed jobs automatically (3 attempts)
"},{"location":"v2/frontend/pages/admin/email-queue-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#ant-design-components-used","title":"Ant Design Components Used","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#statistics-card-grid","title":"Statistics Card Grid","text":"
<Row gutter={[16, 16]}>\n  <Col xs={12} sm={6}>\n    <Card>\n      <Statistic\n        title=\"Waiting\"\n        value={stats?.waiting ?? 0}\n        valueStyle={{ color: '#1890ff' }}\n      />\n    </Card>\n  </Col>\n  <Col xs={12} sm={6}>\n    <Card>\n      <Statistic\n        title=\"Active\"\n        value={stats?.active ?? 0}\n        valueStyle={{ color: '#52c41a' }}\n      />\n    </Card>\n  </Col>\n  <Col xs={12} sm={6}>\n    <Card>\n      <Statistic\n        title=\"Completed\"\n        value={stats?.completed ?? 0}\n      />\n    </Card>\n  </Col>\n  <Col xs={12} sm={6}>\n    <Card>\n      <Statistic\n        title=\"Failed\"\n        value={stats?.failed ?? 0}\n        valueStyle={{ color: '#ff4d4f' }}\n      />\n    </Card>\n  </Col>\n</Row>\n

Responsive Grid: - Mobile (xs, <576px): 2 columns (Waiting/Active on top row, Completed/Failed on bottom) - Tablet/Desktop (sm+, \u2265576px): 4 columns (all cards in single row)

Color-Coded Values: - Waiting: Blue (#1890ff) \u2014 informational, jobs pending - Active: Green (#52c41a) \u2014 success, jobs processing - Completed: Gray (default) \u2014 neutral, jobs done - Failed: Red (#ff4d4f) \u2014 error, jobs failed

"},{"location":"v2/frontend/pages/admin/email-queue-page/#header-actions","title":"Header Actions","text":"
const headerActions = useMemo(() => (\n  <Space>\n    {stats && (\n      <Tag color={stats.paused ? 'orange' : 'green'}>\n        {stats.paused ? 'PAUSED' : 'RUNNING'}\n      </Tag>\n    )}\n    <Button icon={<ReloadOutlined />} onClick={fetchStats} loading={loading}>\n      Refresh\n    </Button>\n    <Button\n      icon={stats?.paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}\n      onClick={handlePauseResume}\n      loading={actionLoading}\n    >\n      {stats?.paused ? 'Resume' : 'Pause'}\n    </Button>\n    <Button\n      icon={<DeleteOutlined />}\n      onClick={handleClean}\n      loading={actionLoading}\n    >\n      Clean Old Jobs\n    </Button>\n  </Space>\n), [stats, loading, actionLoading, fetchStats, handlePauseResume, handleClean]);\n

Dynamic Elements: - Status Tag: Color and text change based on stats.paused boolean - Pause/Resume Button: Icon and label toggle based on current state - Loading States: Separate loading states for refresh (loading) and actions (actionLoading)

"},{"location":"v2/frontend/pages/admin/email-queue-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#local-state-no-zustand-store","title":"Local State (No Zustand Store)","text":"
const [stats, setStats] = useState<QueueStats | null>(null);\nconst [loading, setLoading] = useState(false);\nconst [actionLoading, setActionLoading] = useState(false);\n

State Variables: - stats (QueueStats | null): Current queue statistics (waiting, active, completed, failed, paused) - loading (boolean): Refresh button loading state - actionLoading (boolean): Pause/Resume/Clean buttons loading state (shared)

No Global State:

This page does NOT use Zustand stores. Queue statistics are fetched directly from the API and stored in local state. This is appropriate because: - Queue stats are admin-only monitoring data - Data changes frequently (auto-refresh every 10 seconds) - No need to share state between pages - Simpler architecture without store overhead

"},{"location":"v2/frontend/pages/admin/email-queue-page/#auto-refresh-with-useeffect","title":"Auto-Refresh with useEffect","text":"
const fetchStats = useCallback(async () => {\n  setLoading(true);\n  try {\n    const { data } = await api.get<QueueStats>('/email-queue/stats');\n    setStats(data);\n  } catch {\n    message.error('Failed to load queue stats');\n  } finally {\n    setLoading(false);\n  }\n}, []);\n\nuseEffect(() => {\n  fetchStats();\n  const interval = setInterval(fetchStats, 10_000);  // Refresh every 10 seconds\n  return () => clearInterval(interval);\n}, [fetchStats]);\n

Auto-Refresh Strategy:

Why 10 Seconds?

"},{"location":"v2/frontend/pages/admin/email-queue-page/#usecallback-optimization","title":"useCallback Optimization","text":"
const fetchStats = useCallback(async () => {\n  // ... fetch logic\n}, []);\n\nconst handlePauseResume = useCallback(async () => {\n  // ... pause/resume logic\n}, [stats, fetchStats]);\n\nconst handleClean = useCallback(async () => {\n  // ... clean logic\n}, [fetchStats]);\n

Why useCallback?

"},{"location":"v2/frontend/pages/admin/email-queue-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose Auth GET /api/email-queue/stats Get queue statistics Required POST /api/email-queue/pause Pause queue processing Required POST /api/email-queue/resume Resume queue processing Required POST /api/email-queue/clean Clean completed jobs Required"},{"location":"v2/frontend/pages/admin/email-queue-page/#load-queue-statistics","title":"Load Queue Statistics","text":"

Request:

const { data } = await api.get<QueueStats>('/email-queue/stats');\n

Response (200 OK):

{\n  \"waiting\": 23,\n  \"active\": 2,\n  \"completed\": 1487,\n  \"failed\": 12,\n  \"paused\": false\n}\n

Response Fields: - waiting (number): Jobs queued but not yet picked up by worker - active (number): Jobs currently being processed by worker - completed (number): Successfully completed jobs (still in Redis) - failed (number): Jobs that failed after all retry attempts - paused (boolean): Whether queue is paused (true) or running (false)

Backend Calculation:

// api/src/modules/influence/email-queue/email-queue.routes.ts\nimport { emailQueueService } from '@/services/email-queue.service';\n\nconst queue = emailQueueService.getQueue();\nconst counts = await queue.getJobCounts();\nconst isPaused = await queue.isPaused();\n\nreturn {\n  waiting: counts.waiting,\n  active: counts.active,\n  completed: counts.completed,\n  failed: counts.failed,\n  paused: isPaused,\n};\n

Job Count Breakdown:

"},{"location":"v2/frontend/pages/admin/email-queue-page/#pause-queue","title":"Pause Queue","text":"

Request:

await api.post('/email-queue/pause');\n

Response (200 OK):

{\n  \"message\": \"Queue paused\"\n}\n

Backend Implementation:

await queue.pause();\nreturn { message: 'Queue paused' };\n

Effect: - Queue stops picking up new jobs from \"waiting\" state - Currently active jobs continue to completion - New jobs can still be added to queue (will wait until resumed)

"},{"location":"v2/frontend/pages/admin/email-queue-page/#resume-queue","title":"Resume Queue","text":"

Request:

await api.post('/email-queue/resume');\n

Response (200 OK):

{\n  \"message\": \"Queue resumed\"\n}\n

Backend Implementation:

await queue.resume();\nreturn { message: 'Queue resumed' };\n

Effect: - Queue starts picking up jobs from \"waiting\" state - Workers process jobs according to concurrency setting (default: 1 job at a time)

"},{"location":"v2/frontend/pages/admin/email-queue-page/#clean-completed-jobs","title":"Clean Completed Jobs","text":"

Request:

const { data } = await api.post<{ cleaned: number }>('/email-queue/clean');\n

Response (200 OK):

{\n  \"cleaned\": 1487,\n  \"message\": \"Cleaned 1487 completed jobs\"\n}\n

Response Fields: - cleaned (number): Number of jobs removed from Redis - message (string): Confirmation message

Backend Implementation:

const completedJobs = await queue.getCompleted();\nawait queue.clean(0, 'completed');  // Remove all completed jobs\nreturn {\n  cleaned: completedJobs.length,\n  message: `Cleaned ${completedJobs.length} completed jobs`,\n};\n

Important: This only removes jobs in \"completed\" state. Failed jobs are preserved for troubleshooting.

"},{"location":"v2/frontend/pages/admin/email-queue-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#complete-pauseresume-flow","title":"Complete Pause/Resume Flow","text":"
const handlePauseResume = useCallback(async () => {\n  if (!stats) return;  // Guard: stats must be loaded\n  setActionLoading(true);\n  try {\n    // Determine action based on current state\n    const action = stats.paused ? 'resume' : 'pause';\n\n    // Send POST request\n    await api.post(`/email-queue/${action}`);\n\n    // Show success message\n    message.success(`Queue ${action}d`);\n\n    // Refresh statistics to reflect new state\n    fetchStats();\n  } catch {\n    message.error('Action failed');\n  } finally {\n    setActionLoading(false);\n  }\n}, [stats, fetchStats]);\n

Key Steps: 1. Guard clause: Ensure stats are loaded before determining action 2. Conditional action: Pause if running, resume if paused 3. Dynamic message: Show \"Queue paused\" or \"Queue resumed\" 4. Refresh stats: Update UI to reflect new queue state 5. Error handling: Generic error message (no sensitive details) 6. Always clear loading state in finally block

"},{"location":"v2/frontend/pages/admin/email-queue-page/#clean-old-jobs-flow","title":"Clean Old Jobs Flow","text":"
const handleClean = useCallback(async () => {\n  setActionLoading(true);\n  try {\n    // Send clean request\n    const { data } = await api.post<{ cleaned: number }>('/email-queue/clean');\n\n    // Show detailed success message\n    message.success(`Cleaned ${data.cleaned} completed jobs`);\n\n    // Refresh statistics (completed count should be 0 now)\n    fetchStats();\n  } catch {\n    message.error('Clean failed');\n  } finally {\n    setActionLoading(false);\n  }\n}, [fetchStats]);\n

Key Steps: 1. Set loading state before API call 2. Extract cleaned count from response 3. Show specific count in success message (confirms action worked) 4. Refresh stats to update completed count (should drop to 0) 5. Generic error message on failure

"},{"location":"v2/frontend/pages/admin/email-queue-page/#auto-refresh-setup","title":"Auto-Refresh Setup","text":"
useEffect(() => {\n  // Initial load on mount\n  fetchStats();\n\n  // Set up 10-second auto-refresh interval\n  const interval = setInterval(fetchStats, 10_000);\n\n  // Cleanup interval on unmount (prevents memory leak)\n  return () => {\n    clearInterval(interval);\n    console.log('Email queue auto-refresh stopped');\n  };\n}, [fetchStats]);\n

Cleanup Importance:

If interval is not cleared on unmount: - Memory leak (interval continues running in background) - API calls continue even after user navigates away - Multiple overlapping intervals if user returns to page

"},{"location":"v2/frontend/pages/admin/email-queue-page/#header-actions-with-usememo","title":"Header Actions with useMemo","text":"
const headerActions = useMemo(() => (\n  <Space>\n    {stats && (\n      <Tag color={stats.paused ? 'orange' : 'green'}>\n        {stats.paused ? 'PAUSED' : 'RUNNING'}\n      </Tag>\n    )}\n    <Button icon={<ReloadOutlined />} onClick={fetchStats} loading={loading}>\n      Refresh\n    </Button>\n    <Button\n      icon={stats?.paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}\n      onClick={handlePauseResume}\n      loading={actionLoading}\n    >\n      {stats?.paused ? 'Resume' : 'Pause'}\n    </Button>\n    <Button\n      icon={<DeleteOutlined />}\n      onClick={handleClean}\n      loading={actionLoading}\n    >\n      Clean Old Jobs\n    </Button>\n  </Space>\n), [stats, loading, actionLoading, fetchStats, handlePauseResume, handleClean]);\n\nuseEffect(() => {\n  setPageHeader({ title: 'Email Queue', actions: headerActions });\n  return () => setPageHeader(null);\n}, [setPageHeader, headerActions]);\n

Why useMemo?

"},{"location":"v2/frontend/pages/admin/email-queue-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#10-second-auto-refresh","title":"10-Second Auto-Refresh","text":"

Queue statistics update every 10 seconds:

const interval = setInterval(fetchStats, 10_000);\n

Performance Impact: - API Load: 1 request per 10 seconds = 6 requests/minute (very manageable) - Redis Queries: Each stats request queries Redis (fast, <10ms) - Network: Minimal payload (~100 bytes JSON response)

Comparison to Dashboard: - Dashboard: 30-second refresh, 6 API calls in parallel - Email Queue: 10-second refresh, 1 API call - Email Queue is more frequent but lighter weight

"},{"location":"v2/frontend/pages/admin/email-queue-page/#shared-action-loading-state","title":"Shared Action Loading State","text":"

All action buttons share single actionLoading state:

const [actionLoading, setActionLoading] = useState(false);\n\n<Button loading={actionLoading} onClick={handlePauseResume}>Pause</Button>\n<Button loading={actionLoading} onClick={handleClean}>Clean Old Jobs</Button>\n

Why Shared State?

Trade-off:

User cannot trigger multiple actions at once (e.g., pause + clean). This is acceptable because: - Actions are fast (< 1 second) - Concurrent actions could cause conflicts (pausing while cleaning) - Simpler mental model for user

"},{"location":"v2/frontend/pages/admin/email-queue-page/#silent-refresh","title":"Silent Refresh","text":"

Auto-refresh doesn't show loading spinner:

const fetchStats = useCallback(async () => {\n  setLoading(true);  // But this doesn't affect UI during auto-refresh\n  try {\n    const { data } = await api.get<QueueStats>('/email-queue/stats');\n    setStats(data);\n  } catch {\n    message.error('Failed to load queue stats');\n  } finally {\n    setLoading(false);\n  }\n}, []);\n

Why Silent?

"},{"location":"v2/frontend/pages/admin/email-queue-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#mobile-layout","title":"Mobile Layout","text":"

Statistics cards adapt to mobile viewports:

<Row gutter={[16, 16]}>\n  <Col xs={12} sm={6}>  {/* Half width mobile, quarter width desktop */}\n    <Card><Statistic title=\"Waiting\" value={23} /></Card>\n  </Col>\n  {/* Repeat for other cards */}\n</Row>\n

Responsive Grid: - Mobile (xs, <576px): 2\u00d72 grid (Waiting/Active on row 1, Completed/Failed on row 2) - Tablet/Desktop (sm+, \u2265576px): 1\u00d74 grid (all cards in single row)

"},{"location":"v2/frontend/pages/admin/email-queue-page/#header-actions_1","title":"Header Actions","text":"

Header actions are part of AppLayout's page header:

useEffect(() => {\n  setPageHeader({ title: 'Email Queue', actions: headerActions });\n  return () => setPageHeader(null);\n}, [setPageHeader, headerActions]);\n

Mobile Behavior:

AppLayout automatically collapses header actions into hamburger menu on mobile: - Desktop: Actions visible in header - Mobile: Actions in dropdown menu (hamburger icon)

"},{"location":"v2/frontend/pages/admin/email-queue-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#keyboard-navigation","title":"Keyboard Navigation","text":"

All interactive elements are keyboard-accessible:

Buttons: - Tab: Focus on next button (Refresh \u2192 Pause \u2192 Clean) - Enter/Space: Activate focused button - Escape: Blur focused button

Auto-refresh: - No keyboard interaction needed (automatic updates)

"},{"location":"v2/frontend/pages/admin/email-queue-page/#screen-reader-support","title":"Screen Reader Support","text":"

All elements have proper ARIA labels:

Statistics Cards:

<Statistic\n  title=\"Waiting\"\n  value={stats?.waiting ?? 0}\n  aria-label={`${stats?.waiting ?? 0} waiting jobs`}\n/>\n

Status Tag:

<Tag\n  color={stats.paused ? 'orange' : 'green'}\n  aria-label={`Queue status: ${stats.paused ? 'paused' : 'running'}`}\n>\n  {stats.paused ? 'PAUSED' : 'RUNNING'}\n</Tag>\n

Action Buttons:

<Button\n  icon={<ReloadOutlined />}\n  onClick={fetchStats}\n  aria-label=\"Refresh queue statistics\"\n>\n  Refresh\n</Button>\n

"},{"location":"v2/frontend/pages/admin/email-queue-page/#color-contrast","title":"Color Contrast","text":"

All color-coded elements meet WCAG AA standards:

Statistic Values: - Waiting (blue): #1890ff on white = 4.5:1 contrast (AA) - Active (green): #52c41a on white = 3.0:1 contrast (AA for large text) - Completed (gray): rgba(0,0,0,0.85) on white = 13.6:1 contrast (AAA) - Failed (red): #ff4d4f on white = 4.5:1 contrast (AA)

Status Tags: - RUNNING (green): #52c41a background with white text = 3.5:1 contrast (AA for large text) - PAUSED (orange): #fa8c16 background with white text = 3.2:1 contrast (AA for large text)

"},{"location":"v2/frontend/pages/admin/email-queue-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#statistics-not-updating","title":"Statistics Not Updating","text":"

Problem: Navigate to Email Queue page, statistics load initially, but don't update after 10 seconds.

Diagnosis:

Check browser console for errors:

// Expected: No errors every 10 seconds\n// If errors appear every 10 seconds, auto-refresh is running but failing\nGET /api/email-queue/stats 401 Unauthorized\n

Possible Causes:

  1. JWT token expired:
  2. Access token expired, refresh token not working
  3. User needs to log out and log back in

  4. Interval cleared prematurely:

  5. Component unmounted and remounted (React Strict Mode in development)
  6. useEffect cleanup called too early

  7. Backend API down:

  8. API container not running
  9. Email queue service crashed

Solution:

  1. For token issues:
  2. Refresh page to trigger token refresh
  3. If that fails, log out and log back in
  4. Check JWT_ACCESS_SECRET and JWT_REFRESH_SECRET env vars

  5. For interval issues:

  6. Accept that development mode unmounts/remounts components
  7. Ensure production build works correctly (no double mounting)

  8. For backend issues:

  9. Check API container: docker compose ps api
  10. Check API logs: docker compose logs -f api | grep email-queue
  11. Restart API: docker compose restart api
"},{"location":"v2/frontend/pages/admin/email-queue-page/#pause-button-not-working","title":"Pause Button Not Working","text":"

Problem: Click \"Pause\" button, success message appears, but queue status remains \"RUNNING\" (green).

Diagnosis:

Check API logs:

docker compose logs -f api | grep \"Queue pause\"\n

Expected output:

API: Queue paused successfully\n

Actual output:

API: Error pausing queue: Queue not initialized\n

Possible Causes:

  1. BullMQ queue not initialized:
  2. Email queue service failed to start
  3. Redis connection error during queue initialization

  4. Redis connection lost:

  5. Redis container down
  6. Network connectivity issue between API and Redis

  7. Multiple workers:

  8. Multiple API containers running, only one paused
  9. Other workers continue processing jobs

Solution:

  1. For queue initialization:
  2. Check email queue service: docker compose logs api | grep \"Email queue service\"
  3. Expected: \"Email queue service started successfully\"
  4. If missing, check Redis connection

  5. For Redis issues:

  6. Check Redis container: docker compose ps redis
  7. Test Redis connection: docker compose exec redis redis-cli PING
  8. Expected: \"PONG\"
  9. If down, restart: docker compose restart redis

  10. For multiple workers:

  11. Check running API containers: docker compose ps api
  12. Scale down to single instance: docker compose up -d --scale api=1
"},{"location":"v2/frontend/pages/admin/email-queue-page/#failed-count-increasing","title":"\"Failed\" Count Increasing","text":"

Problem: \"Failed\" count increases from 5 to 50 over time.

Diagnosis:

Check failed jobs in Redis:

docker compose exec redis redis-cli\n> LRANGE bull:email-queue:failed 0 -1\n

Check API logs for failure reasons:

docker compose logs -f api | grep \"Email job failed\"\n

Common error messages:

Email job failed: SMTP connection timeout\nEmail job failed: Authentication failed (535)\nEmail job failed: Recipient address rejected\n

Possible Causes:

  1. SMTP server issues:
  2. SMTP server down (connection timeout)
  3. Invalid credentials (authentication failed)
  4. Rate limiting (too many emails sent)

  5. Invalid recipient addresses:

  6. Email addresses with typos
  7. Non-existent domains
  8. Blocked by recipient server

  9. Network connectivity:

  10. Firewall blocking SMTP ports (25, 587, 465)
  11. DNS resolution failure

Solution:

  1. For SMTP server issues:
  2. Test SMTP connection: Navigate to /app/settings, click \"Test Connection\"
  3. Update SMTP credentials if authentication failed
  4. Wait 5 minutes if rate limited, then resume queue

  5. For invalid addresses:

  6. Review campaign email list: Navigate to /app/influence/campaigns
  7. Check representative email addresses: Navigate to /app/influence/representatives
  8. Delete invalid addresses or update to correct ones

  9. For network issues:

  10. Check firewall rules: sudo iptables -L | grep 587
  11. Test DNS: nslookup smtp.protonmail.ch
  12. Test SMTP port: telnet smtp.protonmail.ch 587
"},{"location":"v2/frontend/pages/admin/email-queue-page/#clean-button-removes-all-jobs","title":"Clean Button Removes All Jobs","text":"

Problem: Click \"Clean Old Jobs\" expecting to remove only completed jobs, but all jobs disappear (waiting + completed).

Diagnosis:

Check API logs:

docker compose logs api | grep \"clean\"\n

Expected:

Cleaned 1487 completed jobs\n

Actual:

Cleaned 1500 jobs (completed + waiting + failed)\n

Possible Causes:

  1. Backend bug:
  2. Clean endpoint removing all job types, not just completed
  3. BullMQ clean() called with wrong parameters

  4. User misunderstanding:

  5. Clean button label unclear (should say \"Clean Completed Jobs\")
  6. User expected to remove failed jobs too

Solution:

  1. For backend bug (developer fix):
  2. Update clean endpoint to only remove completed jobs:
    await queue.clean(0, 'completed');  // Only completed, not 'failed' or 'waiting'\n
  3. Test: Add jobs to queue, click clean, verify waiting/failed jobs remain

  4. For unclear UI:

  5. Update button label: \"Clean Old Jobs\" \u2192 \"Clean Completed Jobs\"
  6. Add tooltip: \"Removes completed jobs from queue. Failed and waiting jobs are preserved.\"
"},{"location":"v2/frontend/pages/admin/email-queue-page/#statistics-show-wrong-counts","title":"Statistics Show Wrong Counts","text":"

Problem: \"Completed\" count shows 1,487, but only 500 emails were sent.

Diagnosis:

Check actual job counts in Redis:

docker compose exec redis redis-cli\n> LLEN bull:email-queue:completed\n

Expected: 1487 (matches UI)

Check campaign email records in database:

SELECT COUNT(*) FROM \"CampaignEmail\" WHERE status = 'SENT';\n

Result: 500 (mismatch with Redis count)

Possible Causes:

  1. Duplicate jobs:
  2. Multiple jobs created for same email
  3. Job retry logic creating duplicates

  4. Test jobs:

  5. Developer testing created many jobs
  6. Test mode emails counting toward total

  7. Redis not cleaned:

  8. Completed jobs from previous campaigns still in Redis
  9. Clean operation not run in months

Solution:

  1. For duplicates:
  2. Investigate job creation logic in CampaignsPage
  3. Ensure single job created per campaign email
  4. Add deduplication: Check if job already exists before creating

  5. For test jobs:

  6. Clean queue after testing: Click \"Clean Old Jobs\"
  7. Use separate test queue (not production queue)

  8. For stale jobs:

  9. Run clean operation regularly (weekly)
  10. Consider auto-clean after 30 days (backend cron job)
"},{"location":"v2/frontend/pages/admin/email-queue-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/","title":"EmailTemplateEditorPage","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#overview","title":"Overview","text":"

File: admin/src/pages/EmailTemplateEditorPage.tsx Route: /app/email-templates/:id/edit Role Requirements: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

EmailTemplateEditorPage is a full-screen Monaco code editor for editing email templates. It provides a split-pane interface with separate editors for HTML and plain text content, real-time preview with sample data, and a variables reference panel. The editor supports Ctrl+S keyboard shortcuts, test email sending, and mobile device detection.

The page displays: - Top toolbar with template metadata (name, category, system status) and action buttons - Subject line input with variable support - Dual Monaco editors (HTML + text) side-by-side - Right sidebar with tabs: Variables, HTML Preview, Text Preview - Sample data inputs for preview rendering - Mobile warning screen (desktop required)

Key Components: - Monaco Editor (@monaco-editor/react) for syntax-highlighted code editing - Ant Design theme tokens for consistent styling - Three-tab right panel (Variables table, HTML iframe preview, Text pre block) - TestEmailModal for sending test emails - Full-screen layout (no AppLayout wrapper)

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#screenshot","title":"Screenshot","text":"

[Screenshot: EmailTemplateEditorPage showing full-screen layout with top toolbar (template name, category tag, Test Email and Save buttons), subject line input, two Monaco editors side-by-side (HTML content on left, plain text on right), and right sidebar with Variables/HTML Preview/Text Preview tabs. Desktop-only interface with dark theme editors.]

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#core-features","title":"Core Features","text":"
  1. Dual Editor Layout
  2. Left pane (40% width): HTML content editor with syntax highlighting
  3. Center pane (40% width): Plain text content editor
  4. Right pane (20% width): Variables reference + previews
  5. VS Dark theme for all Monaco editors
  6. Line numbers, word wrap, no minimap for clean editing

  7. Subject Line Editor

  8. Input field with envelope icon
  9. Supports variable interpolation (e.g., {{CAMPAIGN_NAME}})
  10. Large size input for visibility
  11. Saved together with HTML/text content

  12. Variables Reference Panel

  13. Table showing all template variables with columns:
  14. Sample data input fields for preview
  15. Persists sample values during editing session

  16. Real-Time Previews

  17. HTML Preview Tab: Sandboxed iframe rendering processed HTML
  18. Text Preview Tab: Pre-formatted text block with styling
  19. Live updates when sample data changes
  20. Variable interpolation uses simple string replacement

  21. Save Operations

  22. Save button in toolbar (primary, blue)
  23. Ctrl+S (or Cmd+S on Mac) keyboard shortcut
  24. Creates new version in database
  25. Success message on save
  26. Updates template timestamp

  27. Test Email Functionality

  28. Test Email button opens TestEmailModal
  29. Fill in variable values and recipient
  30. Sends email with current editor content (not saved)
  31. Success message on send

  32. Template Metadata Display

  33. Template name in toolbar
  34. Category tag (color-coded: blue=Influence, green=Map, purple=System)
  35. System template indicator (blue SYSTEM tag)
  36. Back button to return to templates list

  37. Mobile Detection

  38. Detects screens < 768px (md breakpoint)
  39. Shows warning Result component
  40. \"Desktop Required\" message
  41. Back button to return to templates list

  42. Dark Theme Editor

  43. VS Dark Monaco theme
  44. Consistent with code editor expectations
  45. High contrast for readability
  46. Token colors from Ant Design theme
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#opening-editor","title":"Opening Editor","text":"
  1. Navigate from templates list: Click Edit button on EmailTemplatesPage
  2. Route loads: /app/email-templates/:id/edit
  3. Template fetches: Loading spinner while fetching template data
  4. Editor displays: Full-screen layout with template content
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#editing-template","title":"Editing Template","text":"
  1. Modify subject line: Type in top input field, use {{VARIABLES}} as needed
  2. Edit HTML content: Click in left Monaco editor, write HTML markup
  3. Edit text content: Click in center Monaco editor, write plain text
  4. Check syntax: Monaco provides HTML syntax highlighting and error detection
  5. Save changes: Click Save button or press Ctrl+S
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#using-variables","title":"Using Variables","text":"
  1. View variables table: Click Variables tab in right sidebar
  2. Check variable syntax: Copy {{VARIABLE_NAME}} from table
  3. Insert in content: Paste into subject line, HTML, or text editor
  4. Mark required variables: Red \"Required\" tag indicates mandatory variables
  5. Reference descriptions: Read description column for usage guidance
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#previewing-changes","title":"Previewing Changes","text":"
  1. Enter sample data: In Variables tab, fill in input fields below table
  2. Switch to preview: Click \"HTML Preview\" or \"Text Preview\" tab
  3. View rendered output: Iframe shows HTML with variables replaced
  4. Update sample data: Change input values to see different renderings
  5. Verify output: Check that variables interpolate correctly
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#testing-email","title":"Testing Email","text":"
  1. Click Test Email button: Opens TestEmailModal
  2. Fill in variables: Enter values for each template variable
  3. Enter recipient email: Provide test email address
  4. Send test: Click Send button
  5. Check inbox: Verify email received (or MailHog in dev mode)
  6. Review formatting: Check HTML rendering in email client
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#saving-template","title":"Saving Template","text":"
  1. Make changes: Edit subject, HTML, or text content
  2. Save with Ctrl+S: Keyboard shortcut (or click Save button)
  3. Loading state: Save button shows spinner
  4. Success message: \"Template saved successfully\" notification
  5. New version created: Template version history incremented
  6. Continue editing: Can continue making changes and saving again
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#returning-to-list","title":"Returning to List","text":"
  1. Click back button: Arrow icon in top-left of toolbar
  2. Navigate back: Browser back button also works
  3. Unsaved changes: No confirmation prompt (consider implementing)
  4. Route change: Returns to /app/email-templates
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#top-toolbar","title":"Top Toolbar","text":"
<div\n  style={{\n    display: 'flex',\n    alignItems: 'center',\n    justifyContent: 'space-between',\n    padding: '8px 16px',\n    borderBottom: `1px solid ${token.colorBorderSecondary}`,\n    flexShrink: 0,\n  }}\n>\n  <Space>\n    <Button\n      type=\"text\"\n      icon={<ArrowLeftOutlined />}\n      onClick={() => navigate('/app/email-templates')}\n    />\n    <Text strong>{template.name}</Text>\n    <Tag color={getCategoryColor(template.category)}>{template.category}</Tag>\n    {template.isSystem && <Tag color=\"blue\">SYSTEM</Tag>}\n  </Space>\n  <Space>\n    <Button onClick={() => setTestModalOpen(true)} icon={<SendOutlined />}>\n      Test Email\n    </Button>\n    <Button type=\"primary\" loading={saving} onClick={handleSave} icon={<SaveOutlined />}>\n      Save\n    </Button>\n  </Space>\n</div>\n

Layout: - Left: Back button + template metadata (name, category, system status) - Right: Test Email + Save buttons - Height: ~40px (shrinks to fit content) - Border bottom for visual separation

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#subject-line-input","title":"Subject Line Input","text":"
<div style={{ padding: '12px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>\n  <Input\n    value={subjectLine}\n    onChange={(e) => setSubjectLine(e.target.value)}\n    placeholder=\"Email Subject Line (use {{VARIABLES}})\"\n    prefix={<MailOutlined />}\n    size=\"large\"\n  />\n</div>\n

Props: - value: Controlled input with subjectLine state - placeholder: Explains variable syntax - prefix: Envelope icon for visual context - size=\"large\": 40px height for prominence

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#html-editor-monaco","title":"HTML Editor (Monaco)","text":"
<Editor\n  height=\"100%\"\n  language=\"html\"\n  theme=\"vs-dark\"\n  value={htmlContent}\n  onChange={(value) => setHtmlContent(value || '')}\n  options={{\n    minimap: { enabled: false },\n    fontSize: 14,\n    wordWrap: 'on',\n    lineNumbers: 'on',\n    scrollBeyondLastLine: false,\n  }}\n/>\n

Options: - minimap: false - No code minimap (saves space) - fontSize: 14 - Readable code size - wordWrap: 'on' - Wrap long lines instead of horizontal scroll - lineNumbers: 'on' - Show line numbers for reference - scrollBeyondLastLine: false - Don't scroll past last line

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#text-editor-monaco","title":"Text Editor (Monaco)","text":"
<Editor\n  height=\"100%\"\n  language=\"plaintext\"\n  theme=\"vs-dark\"\n  value={textContent}\n  onChange={(value) => setTextContent(value || '')}\n  options={{\n    minimap: { enabled: false },\n    fontSize: 14,\n    wordWrap: 'on',\n    lineNumbers: 'on',\n    scrollBeyondLastLine: false,\n  }}\n/>\n

Same options as HTML editor but language is plaintext (no syntax highlighting).

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#variables-table","title":"Variables Table","text":"
<Table\n  dataSource={template.variables}\n  columns={variableColumns}\n  rowKey=\"id\"\n  size=\"small\"\n  pagination={false}\n/>\n

Columns:

const variableColumns = [\n  {\n    title: 'Variable',\n    dataIndex: 'key',\n    render: (key: string) => <Text code>{'{{' + key + '}}'}</Text>,\n  },\n  {\n    title: 'Label',\n    dataIndex: 'label',\n  },\n  {\n    title: 'Description',\n    dataIndex: 'description',\n    render: (desc: string | null) => <Text type=\"secondary\">{desc || '\u2014'}</Text>,\n  },\n  {\n    title: 'Required',\n    dataIndex: 'isRequired',\n    render: (isRequired: boolean) => (\n      isRequired ? <Tag color=\"red\">Required</Tag> : <Tag>Optional</Tag>\n    ),\n  },\n];\n

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#sample-data-inputs","title":"Sample Data Inputs","text":"
<div style={{ marginTop: 16 }}>\n  <Text strong>Sample Data (for preview):</Text>\n  {template.variables.map((v) => (\n    <div key={v.key} style={{ marginTop: 8 }}>\n      <Text type=\"secondary\" style={{ fontSize: 12 }}>\n        {v.label}\n      </Text>\n      <Input\n        size=\"small\"\n        value={sampleData[v.key] || ''}\n        onChange={(e) => setSampleData({ ...sampleData, [v.key]: e.target.value })}\n        placeholder={v.sampleValue || ''}\n      />\n    </div>\n  ))}\n</div>\n

Pattern: One input per variable, labeled with variable label, placeholder shows default sample value.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#html-preview-iframe","title":"HTML Preview Iframe","text":"
<iframe\n  srcDoc={processedHtml}\n  style={{\n    width: '100%',\n    height: '100%',\n    border: `1px solid ${token.colorBorder}`,\n    borderRadius: 4,\n  }}\n  sandbox=\"allow-same-origin\"\n  title=\"HTML Preview\"\n/>\n

Security: sandbox=\"allow-same-origin\" restricts iframe capabilities (no scripts, no forms).

srcDoc prop: Renders inline HTML without external URL.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#text-preview-block","title":"Text Preview Block","text":"
<pre\n  style={{\n    whiteSpace: 'pre-wrap',\n    fontFamily: 'monospace',\n    fontSize: 12,\n    lineHeight: 1.5,\n    padding: 12,\n    backgroundColor: token.colorBgLayout,\n    borderRadius: 4,\n    border: `1px solid ${token.colorBorder}`,\n  }}\n>\n  {processedText}\n</pre>\n

Styling: - whiteSpace: 'pre-wrap' - Preserve whitespace but wrap long lines - fontFamily: 'monospace' - Fixed-width font like email clients use - Background color for contrast

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#template-processing-function","title":"Template Processing Function","text":"
const processTemplate = (content: string, data: Record<string, string>): string => {\n  let processed = content;\n  Object.entries(data).forEach(([key, value]) => {\n    processed = processed.replace(new RegExp(`{{${key}}}`, 'g'), value);\n  });\n  return processed;\n};\n

Usage:

const processedHtml = processTemplate(htmlContent, sampleData);\nconst processedText = processTemplate(textContent, sampleData);\n

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#local-state","title":"Local State","text":"

Template Data:

const [template, setTemplate] = useState<EmailTemplate | null>(null);\nconst [loading, setLoading] = useState(true);\n

Editor Content:

const [subjectLine, setSubjectLine] = useState('');\nconst [htmlContent, setHtmlContent] = useState('');\nconst [textContent, setTextContent] = useState('');\n

Sample Data for Preview:

const [sampleData, setSampleData] = useState<Record<string, string>>({});\n

UI State:

const [saving, setSaving] = useState(false);\nconst [activeTab, setActiveTab] = useState('variables');\nconst [testModalOpen, setTestModalOpen] = useState(false);\n

Responsive State:

const screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#data-fetching","title":"Data Fetching","text":"

Fetch Template on Mount:

useEffect(() => {\n  const fetchTemplate = async () => {\n    try {\n      const { data } = await api.get<EmailTemplate>(`/email-templates/${id}`);\n      setTemplate(data);\n      setSubjectLine(data.subjectLine);\n      setHtmlContent(data.htmlContent);\n      setTextContent(data.textContent);\n\n      // Initialize sample data from variables\n      const initialSampleData: Record<string, string> = {};\n      data.variables.forEach((v) => {\n        initialSampleData[v.key] = v.sampleValue || '';\n      });\n      setSampleData(initialSampleData);\n    } catch {\n      message.error('Failed to load template');\n      navigate('/app/email-templates');\n    } finally {\n      setLoading(false);\n    }\n  };\n  fetchTemplate();\n}, [id, navigate]);\n

Error Handling: Redirect to templates list if template not found.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#save-handler","title":"Save Handler","text":"
const handleSave = useCallback(async () => {\n  if (!template) return;\n  setSaving(true);\n  try {\n    const { data: updated } = await api.put<EmailTemplate>(`/email-templates/${id}`, {\n      subjectLine,\n      htmlContent,\n      textContent,\n    });\n    setTemplate(updated);\n    message.success('Template saved successfully');\n  } catch (err: unknown) {\n    const msg =\n      (err as { response?: { data?: { error?: string } } })?.response?.data?.error ||\n      'Failed to save template';\n    message.error(msg);\n  } finally {\n    setSaving(false);\n  }\n}, [template, id, subjectLine, htmlContent, textContent]);\n
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#keyboard-shortcut","title":"Keyboard Shortcut","text":"
useEffect(() => {\n  const handler = (e: KeyboardEvent) => {\n    if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n      e.preventDefault();\n      handleSave();\n    }\n  };\n  window.addEventListener('keydown', handler);\n  return () => window.removeEventListener('keydown', handler);\n}, [handleSave]);\n

Why e.preventDefault()? Prevents browser's default \"Save Page\" dialog.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#endpoints-used","title":"Endpoints Used","text":"

GET /email-templates/:id - Fetch template

const { data } = await api.get<EmailTemplate>(`/email-templates/${id}`);\n

Response:

{\n  \"id\": \"tmpl_123\",\n  \"key\": \"campaign_email\",\n  \"name\": \"Campaign Email\",\n  \"category\": \"INFLUENCE\",\n  \"subjectLine\": \"Take action on {{CAMPAIGN_NAME}}\",\n  \"htmlContent\": \"<html><body><h1>{{CAMPAIGN_NAME}}</h1><p>{{MESSAGE}}</p></body></html>\",\n  \"textContent\": \"{{CAMPAIGN_NAME}}\\n\\n{{MESSAGE}}\",\n  \"isActive\": true,\n  \"isSystem\": false,\n  \"variables\": [\n    {\n      \"id\": \"var_1\",\n      \"key\": \"CAMPAIGN_NAME\",\n      \"label\": \"Campaign Name\",\n      \"description\": \"Name of the campaign\",\n      \"isRequired\": true,\n      \"sampleValue\": \"Stop Deforestation\"\n    },\n    {\n      \"id\": \"var_2\",\n      \"key\": \"MESSAGE\",\n      \"label\": \"Message\",\n      \"description\": \"Main message content\",\n      \"isRequired\": true,\n      \"sampleValue\": \"Join us in protecting our forests.\"\n    }\n  ],\n  \"createdAt\": \"2026-01-15T10:00:00Z\",\n  \"updatedAt\": \"2026-02-10T14:30:00Z\"\n}\n

PUT /email-templates/:id - Update template

const { data: updated } = await api.put<EmailTemplate>(`/email-templates/${id}`, {\n  subjectLine: \"Updated subject with {{VARIABLE}}\",\n  htmlContent: \"<html>...</html>\",\n  textContent: \"Plain text...\",\n});\n

Response: Returns updated EmailTemplate object with new updatedAt timestamp.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#keyboard-shortcut-pattern","title":"Keyboard Shortcut Pattern","text":"
const handleSave = useCallback(async () => {\n  // Save logic\n}, [/* dependencies */]);\n\nuseEffect(() => {\n  const handler = (e: KeyboardEvent) => {\n    if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n      e.preventDefault();\n      handleSave();\n    }\n  };\n  window.addEventListener('keydown', handler);\n  return () => window.removeEventListener('keydown', handler);\n}, [handleSave]);\n

Pattern: 1. Use useCallback for save handler with dependencies 2. Add keyboard event listener in useEffect 3. Check Ctrl/Cmd + S key combination 4. Call preventDefault to stop browser save dialog 5. Clean up listener on unmount

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#variable-interpolation","title":"Variable Interpolation","text":"
const processTemplate = (content: string, data: Record<string, string>): string => {\n  let processed = content;\n  Object.entries(data).forEach(([key, value]) => {\n    processed = processed.replace(new RegExp(`{{${key}}}`, 'g'), value);\n  });\n  return processed;\n};\n\n// Usage\nconst processedHtml = processTemplate(htmlContent, sampleData);\n// Input: \"<h1>{{CAMPAIGN_NAME}}</h1>\"\n// Sample data: { CAMPAIGN_NAME: \"Save the Planet\" }\n// Output: \"<h1>Save the Planet</h1>\"\n

Note: This is a simple string replacement for preview. Production email sending uses server-side template engine with proper escaping.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#mobile-detection","title":"Mobile Detection","text":"
const screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n\nif (isMobile) {\n  return (\n    <Result\n      status=\"warning\"\n      title=\"Desktop Required\"\n      subTitle=\"The email template editor requires a desktop browser.\"\n      extra={\n        <Button type=\"primary\" onClick={() => navigate('/app/email-templates')}>\n          Back to Templates\n        </Button>\n      }\n    />\n  );\n}\n

Breakpoint: md = 768px (Ant Design standard)

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#sample-data-initialization","title":"Sample Data Initialization","text":"
// Initialize sample data from template variables\nconst initialSampleData: Record<string, string> = {};\ndata.variables.forEach((v) => {\n  initialSampleData[v.key] = v.sampleValue || '';\n});\nsetSampleData(initialSampleData);\n

Pattern: Pre-fill sample data inputs with default sample values from variable definitions.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#category-color-helper","title":"Category Color Helper","text":"
const getCategoryColor = (category: EmailTemplateCategory): string => {\n  const colors: Record<EmailTemplateCategory, string> = {\n    INFLUENCE: 'blue',\n    MAP: 'green',\n    SYSTEM: 'purple',\n  };\n  return colors[category];\n};\n

Consistent with EmailTemplatesPage color scheme.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#monaco-editor-lazy-loading","title":"Monaco Editor Lazy Loading","text":"

Monaco Editor is loaded via CDN when component mounts:

import Editor from '@monaco-editor/react';\n

Bundle size: Monaco not included in main bundle (reduces initial load).

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#usecallback-for-save-handler","title":"useCallback for Save Handler","text":"
const handleSave = useCallback(async () => { /* ... */ }, [template, id, subjectLine, htmlContent, textContent]);\n

Why: Prevents recreation on every render, essential for keyboard shortcut listener dependency.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#controlled-inputs","title":"Controlled Inputs","text":"

All three editors (subject, HTML, text) use controlled state:

<Input value={subjectLine} onChange={(e) => setSubjectLine(e.target.value)} />\n<Editor value={htmlContent} onChange={(value) => setHtmlContent(value || '')} />\n

Tradeoff: Controlled inputs = React re-renders on every keystroke, but ensures state consistency.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#iframe-preview-updates","title":"Iframe Preview Updates","text":"

Preview iframe updates only when: 1. Sample data changes 2. Editor content changes (via processedHtml dependency)

No automatic refresh timer needed.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#mobile-warning","title":"Mobile Warning","text":"
const isMobile = !screens.md;\n\nif (isMobile) {\n  return <Result status=\"warning\" title=\"Desktop Required\" />;\n}\n

Screens < 768px: Show warning, don't render editor (unusable on small screens).

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#full-screen-layout","title":"Full-Screen Layout","text":"
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>\n  {/* Toolbar */}\n  <div style={{ flexShrink: 0 }}>...</div>\n\n  {/* Editors */}\n  <div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>...</div>\n</div>\n

height: 100vh ensures full viewport height, no scrolling.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#flex-layout","title":"Flex Layout","text":"
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>\n  <div style={{ flex: '0 0 40%' }}>HTML Editor</div>\n  <div style={{ flex: '0 0 40%' }}>Text Editor</div>\n  <div style={{ flex: '0 0 20%' }}>Sidebar</div>\n</div>\n

Flex basis percentages: Fixed width columns, no shrinking/growing.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#keyboard-navigation","title":"Keyboard Navigation","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#button-labels","title":"Button Labels","text":"
<Button icon={<SaveOutlined />}>Save</Button>\n<Button icon={<SendOutlined />}>Test Email</Button>\n

Not icon-only buttons \u2013 text labels for clarity.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#input-placeholders","title":"Input Placeholders","text":"
<Input placeholder=\"Email Subject Line (use {{VARIABLES}})\" />\n

Descriptive placeholder explains variable syntax.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#preview-iframe-sandbox","title":"Preview Iframe Sandbox","text":"
<iframe sandbox=\"allow-same-origin\" />\n

Security: Restricts iframe capabilities (no JavaScript execution from injected HTML).

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#template-not-loading","title":"Template Not Loading","text":"

Symptoms: - Loading spinner forever - Error message \"Failed to load template\" - Redirect to templates list

Causes: 1. Invalid template ID in URL 2. API server down 3. Template deleted 4. Permission denied

Solutions:

# Check API logs\ndocker compose logs -f api\n\n# Test API endpoint\ncurl -H \"Authorization: Bearer <token>\" \\\n  http://localhost:4000/email-templates/tmpl_123\n\n# Verify template exists in database\ndocker compose exec api npx prisma studio\n# Navigate to EmailTemplate model, search by ID\n

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#save-not-working","title":"Save Not Working","text":"

Symptoms: - Clicking Save does nothing - Ctrl+S has no effect - No success/error message

Causes: 1. handleSave callback not defined 2. Keyboard listener not registered 3. Network error

Debug:

const handleSave = useCallback(async () => {\n  console.log('Save triggered');\n  console.log('Template ID:', id);\n  console.log('Content:', { subjectLine, htmlContent, textContent });\n  // ... rest of save logic\n}, [template, id, subjectLine, htmlContent, textContent]);\n

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#preview-not-updating","title":"Preview Not Updating","text":"

Symptoms: - Changing sample data doesn't update preview - Preview shows old content

Causes: 1. processTemplate function not called 2. Sample data state not updating 3. Iframe not re-rendering

Debug:

const processedHtml = processTemplate(htmlContent, sampleData);\nconsole.log('Sample data:', sampleData);\nconsole.log('Processed HTML:', processedHtml);\n

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#variables-not-showing","title":"Variables Not Showing","text":"

Symptoms: - Variables table empty - Sample data inputs not rendering

Cause: - Template has no variables defined

Expected Behavior: - If template.variables is empty array, table shows no rows - This is valid (template may not use variables)

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#mobile-warning-not-showing","title":"Mobile Warning Not Showing","text":"

Symptoms: - Editor renders on mobile (broken layout)

Cause: - Breakpoint detection not working

Debug:

const screens = Grid.useBreakpoint();\nconsole.log('Breakpoints:', screens);\nconsole.log('Is mobile:', !screens.md);\n

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#monaco-editor-blank","title":"Monaco Editor Blank","text":"

Symptoms: - Editor pane shows nothing (white/black) - No code visible

Causes: 1. Monaco CDN failed to load 2. Content is empty string 3. Height not set correctly

Solutions:

// Check if content loaded\nconsole.log('HTML content length:', htmlContent.length);\n\n// Verify Monaco loaded\nimport Editor from '@monaco-editor/react';\nconsole.log('Monaco Editor component:', Editor);\n\n// Check editor height\n<Editor height=\"100%\" /> // Ensure parent has defined height\n

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#backend-integration","title":"Backend Integration","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#frontend-pages","title":"Frontend Pages","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#frontend-components","title":"Frontend Components","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#features_1","title":"Features","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#user-guides","title":"User Guides","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#external-resources","title":"External Resources","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#related-technologies","title":"Related Technologies","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/","title":"EmailTemplatesPage","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#overview","title":"Overview","text":"

File: admin/src/pages/EmailTemplatesPage.tsx Route: /app/email-templates Role Requirements: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

EmailTemplatesPage is the email template management interface for the V2 system. It provides CRUD operations for email templates used throughout the platform (campaigns, shifts, notifications), with versioning support, test email functionality, and categorization. The page manages HTML + text content templates with variable substitution for dynamic content.

The page integrates with the email template system to provide: - Template library with search and filtering - Category organization (Influence, Map, System) - Active/inactive status management - Test email sending with sample data - Version history with rollback capability - System template protection (cannot delete)

Key Components: - Ant Design Table for template list with pagination - Input with 300ms debounced search - Select filters for category and active status - TestEmailModal for sending test emails - VersionHistoryDrawer for viewing and restoring previous versions - Action buttons (Edit, Test, Versions, Delete)

"},{"location":"v2/frontend/pages/admin/email-templates-page/#screenshot","title":"Screenshot","text":"

[Screenshot: EmailTemplatesPage showing template list with search bar, category/active filters, and table with columns for Name, Category, Subject, Active status, Updated timestamp, and Actions (Edit, Test, Versions, Delete buttons). System templates show SYSTEM tag and no delete button.]

"},{"location":"v2/frontend/pages/admin/email-templates-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#core-features","title":"Core Features","text":"
  1. Template List Management
  2. Paginated table showing all email templates (default 20 per page)
  3. Name column shows template name + key, system templates marked with blue SYSTEM tag
  4. Category column color-coded (blue=Influence, green=Map, purple=System)
  5. Subject line preview (truncated to 50 chars)
  6. Active/Inactive badge status
  7. Relative timestamp (e.g., \"2 hours ago\")
  8. Four action buttons per row (Edit, Test, Versions, Delete)

  9. Search & Filtering

  10. Real-time search by name or key (300ms debounce)
  11. Category filter dropdown (All, Influence, Map, System)
  12. Active status filter (All, Active, Inactive)
  13. Filters trigger automatic refetch with page reset to 1

  14. Template Actions

  15. Edit: Navigate to full-screen Monaco editor (/app/email-templates/:id/edit)
  16. Test: Open modal to send test email with sample data
  17. Versions: Open drawer showing version history with rollback options
  18. Delete: Popconfirm with warning (only for non-system templates)

  19. Pagination Controls

  20. Page size options: 10, 20, 50, 100
  21. Show total count (e.g., \"Total 15 templates\")
  22. Current page and page size preserved during search/filter operations

  23. System Template Protection

  24. System templates (isSystem: true) cannot be deleted
  25. Delete button hidden for system templates
  26. Blue SYSTEM tag displayed in name column

  27. Test Email Modal

  28. Fill in variable values with form inputs
  29. Send test email to specified recipient
  30. Uses template's current HTML + text content
  31. Success message on send

  32. Version History

  33. Drawer showing all historical versions
  34. View previous subject lines, HTML, and text content
  35. Rollback to any previous version
  36. Refetches template list after rollback
"},{"location":"v2/frontend/pages/admin/email-templates-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#viewing-templates","title":"Viewing Templates","text":"
  1. Navigate to page: Admin sidebar \u2192 Email \u2192 Templates
  2. Browse templates: Table shows all templates with pagination
  3. View details: Click on template name or use filters to narrow list
  4. Check status: Green \"Active\" badge = enabled, gray \"Inactive\" badge = disabled
"},{"location":"v2/frontend/pages/admin/email-templates-page/#searching-templates","title":"Searching Templates","text":"
  1. Enter search query: Type in search bar (name or key)
  2. Debounced search: 300ms delay prevents excessive API calls
  3. Clear search: Click X icon or clear input
  4. Results update: Table refreshes with matching templates, page resets to 1
"},{"location":"v2/frontend/pages/admin/email-templates-page/#filtering-templates","title":"Filtering Templates","text":"
  1. Select category: Choose from All, Influence, Map, System dropdown
  2. Select active status: Choose from All, Active, Inactive dropdown
  3. Combined filters: Search + category + active status work together
  4. Reset filters: Change dropdowns back to \"All\" or clear search
"},{"location":"v2/frontend/pages/admin/email-templates-page/#editing-template","title":"Editing Template","text":"
  1. Click Edit button: Opens full-screen Monaco editor in new route
  2. Modify content: Edit subject line, HTML, and text content
  3. Save changes: Ctrl+S or click Save button (creates new version)
  4. Return to list: Browser back button or navigate away
"},{"location":"v2/frontend/pages/admin/email-templates-page/#testing-email","title":"Testing Email","text":"
  1. Click Test button: Opens TestEmailModal
  2. Fill in variables: Enter sample data for template variables
  3. Enter recipient: Provide email address for test
  4. Send test: Click Send button
  5. Check result: Success message or error message
  6. Verify email: Check recipient inbox (or MailHog in dev mode)
"},{"location":"v2/frontend/pages/admin/email-templates-page/#viewing-version-history","title":"Viewing Version History","text":"
  1. Click Versions button: Opens VersionHistoryDrawer on right side
  2. Browse versions: See all historical versions with timestamps
  3. View version details: Expand version to see full content
  4. Rollback (if needed): Click Rollback button to restore previous version
  5. Close drawer: Click X or click outside drawer
"},{"location":"v2/frontend/pages/admin/email-templates-page/#deleting-template","title":"Deleting Template","text":"
  1. Verify not system template: Check for absence of SYSTEM tag
  2. Click Delete button: Opens Popconfirm dialog
  3. Read warning: \"This action cannot be undone\"
  4. Confirm deletion: Click OK in popconfirm
  5. Template removed: Table refreshes, template no longer shown
"},{"location":"v2/frontend/pages/admin/email-templates-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#table-component","title":"Table Component","text":"
<Table\n  columns={columns}\n  dataSource={templates}\n  rowKey=\"id\"\n  loading={loading}\n  onChange={handleTableChange}\n  pagination={{\n    current: pagination.page,\n    pageSize: pagination.limit,\n    total: pagination.total,\n    showSizeChanger: true,\n    showTotal: (total) => `Total ${total} templates`,\n    pageSizeOptions: ['10', '20', '50', '100'],\n  }}\n  scroll={{ x: 'max-content' }}\n/>\n

Column Configuration:

Column Dataindex Responsive Render Logic Name name Always visible Shows name + key, SYSTEM tag for system templates Category category Hidden on mobile (['md']) Color-coded tag (blue/green/purple) Subject subjectLine Hidden on small tablets (['lg']) Truncated to 50 chars with ellipsis Active isActive Hidden on mobile Badge (success or default) Updated updatedAt Hidden on mobile Relative time with dayjs fromNow() Actions - Fixed right, 280px Edit, Test, Versions, Delete buttons"},{"location":"v2/frontend/pages/admin/email-templates-page/#search-input","title":"Search Input","text":"
<Input\n  placeholder=\"Search by name or key...\"\n  prefix={<SearchOutlined />}\n  value={search}\n  onChange={(e) => handleSearchChange(e.target.value)}\n  style={{ width: 300 }}\n  allowClear\n/>\n

Debounce Logic:

const handleSearchChange = (value: string) => {\n  setSearch(value);  // Update input immediately\n  clearTimeout(searchTimerRef.current);\n  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);\n};\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#category-filter","title":"Category Filter","text":"
<Select\n  value={categoryFilter}\n  onChange={setCategoryFilter}\n  options={categoryOptions}\n  style={{ width: 180 }}\n/>\n

Options:

const categoryOptions = [\n  { value: 'ALL', label: 'All Categories' },\n  { value: 'INFLUENCE', label: 'Influence' },\n  { value: 'MAP', label: 'Map' },\n  { value: 'SYSTEM', label: 'System' },\n];\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#active-status-filter","title":"Active Status Filter","text":"
<Select\n  value={activeFilter}\n  onChange={setActiveFilter}\n  options={activeOptions}\n  style={{ width: 150 }}\n/>\n

Options:

const activeOptions = [\n  { value: 'ALL', label: 'All Status' },\n  { value: 'ACTIVE', label: 'Active' },\n  { value: 'INACTIVE', label: 'Inactive' },\n];\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#action-buttons","title":"Action Buttons","text":"
<Space wrap>\n  <Button\n    type=\"link\"\n    size=\"small\"\n    icon={<EditOutlined />}\n    onClick={() => navigate(`/app/email-templates/${record.id}/edit`)}\n  >\n    Edit\n  </Button>\n  <Button\n    type=\"link\"\n    size=\"small\"\n    icon={<MailOutlined />}\n    onClick={() => openTestEmailModal(record)}\n  >\n    Test\n  </Button>\n  <Button\n    type=\"link\"\n    size=\"small\"\n    icon={<HistoryOutlined />}\n    onClick={() => openVersionDrawer(record)}\n  >\n    Versions\n  </Button>\n  {!record.isSystem && (\n    <Popconfirm\n      title=\"Delete template?\"\n      description=\"This action cannot be undone.\"\n      onConfirm={() => handleDelete(record.id)}\n    >\n      <Button type=\"link\" size=\"small\" danger icon={<DeleteOutlined />}>\n        Delete\n      </Button>\n    </Popconfirm>\n  )}\n</Space>\n
"},{"location":"v2/frontend/pages/admin/email-templates-page/#testemailmodal","title":"TestEmailModal","text":"

Props: - open: boolean - Modal visibility - template: EmailTemplate - Template to test - onClose: () => void - Close callback - onSuccess: () => void - Success callback

Usage:

<TestEmailModal\n  open={testModalOpen}\n  template={selectedTemplate}\n  onClose={() => {\n    setTestModalOpen(false);\n    setSelectedTemplate(null);\n  }}\n  onSuccess={() => {\n    message.success('Test email sent successfully');\n    setTestModalOpen(false);\n    setSelectedTemplate(null);\n  }}\n/>\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#versionhistorydrawer","title":"VersionHistoryDrawer","text":"

Props: - open: boolean - Drawer visibility - templateId: string - Template ID - templateName: string - Template name for header - onClose: () => void - Close callback - onRollbackSuccess: () => void - Rollback success callback

Usage:

<VersionHistoryDrawer\n  open={versionDrawerOpen}\n  templateId={selectedTemplate.id}\n  templateName={selectedTemplate.name}\n  onClose={() => {\n    setVersionDrawerOpen(false);\n    setSelectedTemplate(null);\n  }}\n  onRollbackSuccess={() => {\n    fetchTemplates();  // Refresh list\n    setVersionDrawerOpen(false);\n    setSelectedTemplate(null);\n  }}\n/>\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#local-state","title":"Local State","text":"

Template List State:

const [templates, setTemplates] = useState<EmailTemplate[]>([]);\nconst [pagination, setPagination] = useState<PaginationMeta>({\n  page: 1,\n  limit: 20,\n  total: 0,\n  totalPages: 0\n});\nconst [loading, setLoading] = useState(false);\n

Search & Filter State:

const [search, setSearch] = useState('');  // Input value (immediate)\nconst [debouncedSearch, setDebouncedSearch] = useState('');  // API query (300ms delay)\nconst searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\nconst [categoryFilter, setCategoryFilter] = useState<EmailTemplateCategory | 'ALL'>('ALL');\nconst [activeFilter, setActiveFilter] = useState<'ALL' | 'ACTIVE' | 'INACTIVE'>('ALL');\n

Modal State:

const [testModalOpen, setTestModalOpen] = useState(false);\nconst [selectedTemplate, setSelectedTemplate] = useState<EmailTemplate | null>(null);\nconst [versionDrawerOpen, setVersionDrawerOpen] = useState(false);\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#data-fetching","title":"Data Fetching","text":"

Fetch Templates Function:

const fetchTemplates = useCallback(\n  async (params?: EmailTemplatesListParams) => {\n    setLoading(true);\n    try {\n      const { data } = await api.get<EmailTemplatesListResponse>('/email-templates', {\n        params: {\n          page: params?.page ?? 1,\n          limit: params?.limit ?? 20,\n          search: params?.search ?? (debouncedSearch || undefined),\n          category: categoryFilter !== 'ALL' ? categoryFilter : undefined,\n          isActive: activeFilter !== 'ALL' ? activeFilter === 'ACTIVE' : undefined,\n        },\n      });\n      setTemplates(data.templates);\n      setPagination(data.pagination);\n    } catch {\n      message.error('Failed to load templates');\n    } finally {\n      setLoading(false);\n    }\n  },\n  [debouncedSearch, categoryFilter, activeFilter]\n);\n

Auto-Refetch on Filter Changes:

useEffect(() => {\n  fetchTemplates({ page: 1 });\n}, [debouncedSearch, categoryFilter, activeFilter]); // Reset to page 1 on filter change\n

Debounce Cleanup:

useEffect(() => {\n  return () => clearTimeout(searchTimerRef.current);\n}, []);\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#helper-functions","title":"Helper Functions","text":"

Category Color Mapping:

const getCategoryColor = (category: EmailTemplateCategory): string => {\n  const colors: Record<EmailTemplateCategory, string> = {\n    INFLUENCE: 'blue',\n    MAP: 'green',\n    SYSTEM: 'purple',\n  };\n  return colors[category];\n};\n

Open Modal/Drawer:

const openTestEmailModal = (template: EmailTemplate) => {\n  setSelectedTemplate(template);\n  setTestModalOpen(true);\n};\n\nconst openVersionDrawer = (template: EmailTemplate) => {\n  setSelectedTemplate(template);\n  setVersionDrawerOpen(true);\n};\n

Delete Handler:

const handleDelete = async (id: string) => {\n  try {\n    await api.delete(`/email-templates/${id}`);\n    message.success('Template deleted');\n    fetchTemplates();  // Refresh list\n  } catch (err: unknown) {\n    const msg =\n      (err as { response?: { data?: { error?: string } } })?.response?.data?.error ||\n      'Failed to delete template';\n    message.error(msg);\n  }\n};\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#endpoints-used","title":"Endpoints Used","text":"

GET /email-templates - List templates with filters

const { data } = await api.get<EmailTemplatesListResponse>('/email-templates', {\n  params: {\n    page: 1,\n    limit: 20,\n    search: 'campaign',\n    category: 'INFLUENCE',\n    isActive: true,\n  },\n});\n

Response:

{\n  \"templates\": [\n    {\n      \"id\": \"tmpl_123\",\n      \"key\": \"campaign_email\",\n      \"name\": \"Campaign Email\",\n      \"category\": \"INFLUENCE\",\n      \"subjectLine\": \"Take action on {{CAMPAIGN_NAME}}\",\n      \"htmlContent\": \"<html>...</html>\",\n      \"textContent\": \"Plain text...\",\n      \"isActive\": true,\n      \"isSystem\": false,\n      \"variables\": [\n        {\n          \"id\": \"var_1\",\n          \"key\": \"CAMPAIGN_NAME\",\n          \"label\": \"Campaign Name\",\n          \"description\": \"Name of the campaign\",\n          \"isRequired\": true,\n          \"sampleValue\": \"Stop Deforestation\"\n        }\n      ],\n      \"createdAt\": \"2026-01-15T10:00:00Z\",\n      \"updatedAt\": \"2026-02-10T14:30:00Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 15,\n    \"totalPages\": 1\n  }\n}\n

DELETE /email-templates/:id - Delete template

await api.delete(`/email-templates/${id}`);\n

Response: 204 No Content on success

"},{"location":"v2/frontend/pages/admin/email-templates-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#debounced-search-implementation","title":"Debounced Search Implementation","text":"
const [search, setSearch] = useState('');\nconst [debouncedSearch, setDebouncedSearch] = useState('');\nconst searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\n\nconst handleSearchChange = (value: string) => {\n  setSearch(value);  // Update input immediately for responsive UI\n  clearTimeout(searchTimerRef.current);  // Cancel previous timer\n  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);\n};\n\n// Cleanup on unmount\nuseEffect(() => {\n  return () => clearTimeout(searchTimerRef.current);\n}, []);\n\n// Trigger API fetch when debouncedSearch changes\nuseEffect(() => {\n  fetchTemplates({ page: 1 });\n}, [debouncedSearch]);\n

Why 300ms? Standard debounce for search inputs balances responsiveness with API efficiency.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#conditional-delete-button","title":"Conditional Delete Button","text":"
{!record.isSystem && (\n  <Popconfirm\n    title=\"Delete template?\"\n    description=\"This action cannot be undone.\"\n    onConfirm={() => handleDelete(record.id)}\n  >\n    <Button type=\"link\" size=\"small\" danger icon={<DeleteOutlined />}>\n      Delete\n    </Button>\n  </Popconfirm>\n)}\n

Pattern: Hide delete button entirely for system templates rather than showing disabled button (clearer UI).

"},{"location":"v2/frontend/pages/admin/email-templates-page/#modal-openclose-pattern","title":"Modal Open/Close Pattern","text":"
// Open modal with selected template\nconst openTestEmailModal = (template: EmailTemplate) => {\n  setSelectedTemplate(template);\n  setTestModalOpen(true);\n};\n\n// Close modal and clear selection\nconst closeTestEmailModal = () => {\n  setTestModalOpen(false);\n  setSelectedTemplate(null);\n};\n\n// Render modal (only when template selected)\n{selectedTemplate && (\n  <TestEmailModal\n    open={testModalOpen}\n    template={selectedTemplate}\n    onClose={closeTestEmailModal}\n    onSuccess={() => {\n      message.success('Test email sent successfully');\n      closeTestEmailModal();\n    }}\n  />\n)}\n

Pattern: Conditional rendering with selectedTemplate && prevents rendering modal with null template.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#category-color-function","title":"Category Color Function","text":"
const getCategoryColor = (category: EmailTemplateCategory): string => {\n  const colors: Record<EmailTemplateCategory, string> = {\n    INFLUENCE: 'blue',\n    MAP: 'green',\n    SYSTEM: 'purple',\n  };\n  return colors[category];\n};\n\n// Usage in column render\nrender: (category: EmailTemplateCategory) => (\n  <Tag color={getCategoryColor(category)}>{category}</Tag>\n)\n
"},{"location":"v2/frontend/pages/admin/email-templates-page/#relative-time-with-dayjs","title":"Relative Time with dayjs","text":"
import dayjs from 'dayjs';\nimport relativeTime from 'dayjs/plugin/relativeTime';\n\ndayjs.extend(relativeTime);\n\n// In column render\nrender: (date: string) => dayjs(date).fromNow()\n// Output: \"2 hours ago\", \"3 days ago\", \"a month ago\"\n
"},{"location":"v2/frontend/pages/admin/email-templates-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#debounced-search","title":"Debounced Search","text":"

Problem: Typing in search input triggers API call on every keystroke (excessive network traffic).

Solution: 300ms debounce timer delays API call until user stops typing.

Implementation:

const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\n\nconst handleSearchChange = (value: string) => {\n  setSearch(value);  // Immediate UI update\n  clearTimeout(searchTimerRef.current);\n  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);\n};\n

Benefit: Reduces API calls by ~80% for typical search behavior.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#usecallback-for-fetch-function","title":"useCallback for Fetch Function","text":"
const fetchTemplates = useCallback(\n  async (params?: EmailTemplatesListParams) => { /* ... */ },\n  [debouncedSearch, categoryFilter, activeFilter]\n);\n

Why: Prevents infinite re-render loop when fetchTemplates is used in useEffect dependency array.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#table-pagination","title":"Table Pagination","text":"

Server-side pagination (not client-side) means only current page data loaded: - Page 1: Load 20 templates - Page 2: Load next 20 templates - Total: 1000 templates \u2192 Only 20 in memory at a time

Benefit: Handles large template libraries without performance degradation.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#component-conditional-rendering","title":"Component Conditional Rendering","text":"
{selectedTemplate && (\n  <TestEmailModal\n    open={testModalOpen}\n    template={selectedTemplate}\n    onClose={closeTestEmailModal}\n  />\n)}\n

Why: Modal only mounted when needed, saves memory and avoids rendering with null props.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#responsive-columns","title":"Responsive Columns","text":"

Column Configuration:

{\n  title: 'Category',\n  dataIndex: 'category',\n  responsive: ['md'],  // Hide on mobile (< 768px)\n},\n{\n  title: 'Subject',\n  dataIndex: 'subjectLine',\n  responsive: ['lg'],  // Hide on tablets (< 992px)\n},\n{\n  title: 'Active',\n  dataIndex: 'isActive',\n  responsive: ['md'],  // Hide on mobile\n},\n{\n  title: 'Updated',\n  dataIndex: 'updatedAt',\n  responsive: ['md'],  // Hide on mobile\n},\n

Mobile View (< 768px): - Visible: Name, Actions - Hidden: Category, Subject, Active, Updated

Tablet View (768px - 991px): - Visible: Name, Category, Active, Updated, Actions - Hidden: Subject

Desktop View (\u2265 992px): - All columns visible

"},{"location":"v2/frontend/pages/admin/email-templates-page/#filter-layout","title":"Filter Layout","text":"
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>\n  <Input style={{ width: 300 }} />\n  <Select style={{ width: 180 }} />\n  <Select style={{ width: 150 }} />\n</div>\n

flexWrap: 'wrap' ensures filters stack vertically on narrow screens.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#table-scroll","title":"Table Scroll","text":"
<Table\n  scroll={{ x: 'max-content' }}\n/>\n

Horizontal scroll on mobile prevents column squishing.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#keyboard-navigation","title":"Keyboard Navigation","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#icon-labels","title":"Icon Labels","text":"

All icon-only buttons have text labels:

<Button icon={<EditOutlined />}>Edit</Button>\n<Button icon={<MailOutlined />}>Test</Button>\n<Button icon={<HistoryOutlined />}>Versions</Button>\n

Not icon-only buttons \u2013 clear action labels improve accessibility.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#search-input_1","title":"Search Input","text":"
<Input\n  placeholder=\"Search by name or key...\"\n  prefix={<SearchOutlined />}\n  allowClear\n/>\n
"},{"location":"v2/frontend/pages/admin/email-templates-page/#popconfirm-for-destructive-actions","title":"Popconfirm for Destructive Actions","text":"
<Popconfirm\n  title=\"Delete template?\"\n  description=\"This action cannot be undone.\"\n  onConfirm={() => handleDelete(record.id)}\n>\n  <Button danger>Delete</Button>\n</Popconfirm>\n

Two-step confirmation prevents accidental deletion (important for accessibility and safety).

"},{"location":"v2/frontend/pages/admin/email-templates-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#templates-not-loading","title":"Templates Not Loading","text":"

Symptoms: - Empty table - Loading spinner never stops - Error message \"Failed to load templates\"

Causes: 1. API server not running (port 4000) 2. Network error 3. Missing authentication token 4. Database connection issue

Solutions:

# Check API server logs\ndocker compose logs -f api\n\n# Verify API is accessible\ncurl -H \"Authorization: Bearer <token>\" http://localhost:4000/email-templates\n\n# Restart API container\ndocker compose restart api\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#search-not-working","title":"Search Not Working","text":"

Symptoms: - Typing in search input doesn't filter results - Search triggers on every keystroke (should debounce)

Causes: 1. Debounce timer not clearing properly 2. debouncedSearch state not updating 3. API not receiving search param

Debug:

// Add console log to verify debounce\nconst handleSearchChange = (value: string) => {\n  console.log('Input value:', value);\n  setSearch(value);\n  clearTimeout(searchTimerRef.current);\n  searchTimerRef.current = setTimeout(() => {\n    console.log('Debounced search:', value);\n    setDebouncedSearch(value);\n  }, 300);\n};\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#delete-button-missing","title":"Delete Button Missing","text":"

Symptoms: - Delete button not visible for some templates

Cause: - Template is a system template (isSystem: true)

Expected Behavior: - System templates cannot be deleted (protected) - Delete button intentionally hidden for system templates

Verification:

// Check template data\nconsole.log('Template:', record.isSystem);\n// If true, delete button correctly hidden\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#modaldrawer-not-opening","title":"Modal/Drawer Not Opening","text":"

Symptoms: - Clicking Test or Versions button does nothing - Modal/drawer remains closed

Causes: 1. selectedTemplate is null 2. State update not triggering 3. Modal/drawer component not rendered

Debug:

const openTestEmailModal = (template: EmailTemplate) => {\n  console.log('Opening test modal for:', template.id);\n  setSelectedTemplate(template);\n  setTestModalOpen(true);\n};\n\n// Check render condition\nconsole.log('Selected template:', selectedTemplate);\nconsole.log('Modal open:', testModalOpen);\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#pagination-not-working","title":"Pagination Not Working","text":"

Symptoms: - Clicking page numbers doesn't load new data - Page stays on 1

Cause: - handleTableChange not wired correctly - Pagination params not passed to API

Debug:

const handleTableChange = (pag: TablePaginationConfig) => {\n  console.log('Page change:', pag.current, pag.pageSize);\n  fetchTemplates({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });\n};\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#subject-line-truncation","title":"Subject Line Truncation","text":"

Symptoms: - Long subject lines cut off without ellipsis

Cause: - CSS ellipsis not applied

Fix:

render: (subject: string) => (\n  <Text ellipsis style={{ maxWidth: 300 }}>\n    {subject.length > 50 ? `${subject.slice(0, 50)}...` : subject}\n  </Text>\n)\n

Alternative: Use Ant Design Typography.Text with ellipsis prop for automatic truncation.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#backend-integration","title":"Backend Integration","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#frontend-components","title":"Frontend Components","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#editor-page","title":"Editor Page","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#features_1","title":"Features","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#user-guides","title":"User Guides","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#troubleshooting_1","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#related-pages","title":"Related Pages","text":""},{"location":"v2/frontend/pages/admin/gitea-page/","title":"GiteaPage","text":""},{"location":"v2/frontend/pages/admin/gitea-page/#overview","title":"Overview","text":"

File: admin/src/pages/GiteaPage.tsx

Route: /app/services/gitea

Role Requirements: Any authenticated user

Purpose: Provides an embedded interface to the Gitea Git repository hosting service via iframe. Gitea is a self-hosted Git service (similar to GitHub/GitLab) that allows developers to manage source code repositories, issues, pull requests, and collaboration. This page embeds Gitea with status monitoring and mobile device detection.

Key Features: - Full-page iframe embed of Gitea service - Service online/offline status monitoring - Mobile device detection with warning screen - \"Refresh\" and \"Open in New Tab\" buttons - Fullbleed layout for maximum repository browser space - Git repository management, issue tracking, pull requests

Layout: AppLayout with fullbleed

"},{"location":"v2/frontend/pages/admin/gitea-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/gitea-page/#1-service-status-monitoring","title":"1. Service Status Monitoring","text":"

Status Display: - Green \"Online\" badge when Gitea is accessible - Red \"Offline\" badge when unavailable - Blue \"Checking...\" badge during status check

"},{"location":"v2/frontend/pages/admin/gitea-page/#2-mobile-device-detection","title":"2. Mobile Device Detection","text":"

Mobile Warning: - BranchesOutlined icon (48px) - Message: \"The Git repository browser requires a desktop browser\" - \"Open in New Tab\" button for external access

"},{"location":"v2/frontend/pages/admin/gitea-page/#3-git-repository-management","title":"3. Git Repository Management","text":"

Gitea Features (within iframe): - Repositories: Create, clone, browse Git repositories - Code Browser: View files, commits, branches, tags - Issues: Bug tracking and feature requests - Pull Requests: Code review and merging workflow - Wiki: Project documentation - Organizations: Team collaboration - Access Control: Public/private repos, user permissions

"},{"location":"v2/frontend/pages/admin/gitea-page/#component-structure","title":"Component Structure","text":"
export default function GiteaPage() {\n  const { setPageHeader } = useOutletContext<AppOutletContext>();\n  const screens = Grid.useBreakpoint();\n  const isMobile = !screens.md;\n\n  const [online, setOnline] = useState<boolean | null>(null);\n  const [config, setConfig] = useState<ServicesConfig | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  const fetchStatus = useCallback(async () => {\n    try {\n      const [statusRes, configRes] = await Promise.all([\n        api.get<ServicesStatus>('/services/status'),\n        api.get<ServicesConfig>('/services/config'),\n      ]);\n      setOnline(statusRes.data.gitea.online);\n      setConfig(configRes.data);\n    } catch {\n      setOnline(false);\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    fetchStatus();\n  }, [fetchStatus]);\n\n  const serviceUrl = config\n    ? buildServiceUrl(config.giteaSubdomain, config.domain, config.giteaPort)\n    : null;\n\n  // Header actions, mobile warning, loading, offline states...\n  // Iframe embed\n\n  return (\n    <iframe\n      src={serviceUrl}\n      style={{\n        width: '100%',\n        height: 'calc(100vh - 64px)',\n        border: 'none',\n        display: 'block',\n      }}\n      title=\"Gitea\"\n    />\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/gitea-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/gitea-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/services/status - Check Gitea health
  2. GET /api/services/config - Fetch subdomain/port config
"},{"location":"v2/frontend/pages/admin/gitea-page/#example-responses","title":"Example Responses","text":"

Status:

{\n  \"gitea\": { \"online\": true }\n}\n

Config:

{\n  \"domain\": \"cmlite.org\",\n  \"giteaSubdomain\": \"git\",\n  \"giteaPort\": 3030\n}\n

Service URL: - Production: http://git.cmlite.org - Development: http://localhost:3030

"},{"location":"v2/frontend/pages/admin/gitea-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/gitea-page/#accessing-gitea","title":"Accessing Gitea","text":"
  1. Navigate to \"Services\" \u2192 \"Git Repository\" in sidebar
  2. Check status badge (Online/Offline)
  3. View Gitea interface in iframe
  4. Or click \"Open in New Tab\" for full window
"},{"location":"v2/frontend/pages/admin/gitea-page/#common-use-cases","title":"Common Use Cases","text":"

Repository Management: - Create new repository for project - Clone repository URL for local development - Browse code, commits, branches

Collaboration: - Create issues for bugs/features - Submit pull requests for code review - Comment on code changes - Merge approved pull requests

Documentation: - Edit project wiki - Update README files - Maintain changelog

"},{"location":"v2/frontend/pages/admin/gitea-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/gitea-page/#problem-gitea-shows-offline","title":"Problem: Gitea Shows \"Offline\"","text":"

Solutions:

  1. Check Docker container:

    docker compose ps gitea\n

  2. Check logs:

    docker compose logs gitea\n

  3. Restart service:

    docker compose restart gitea\n

"},{"location":"v2/frontend/pages/admin/gitea-page/#problem-login-required","title":"Problem: Login Required","text":"

Symptoms: Iframe shows Gitea login screen

Solutions:

  1. Check Gitea credentials in .env:
  2. GITEA_ADMIN_USER
  3. GITEA_ADMIN_PASSWORD

  4. Login manually with admin credentials

  5. Create user account if needed

"},{"location":"v2/frontend/pages/admin/gitea-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/landing-pages-page/","title":"LandingPagesPage","text":""},{"location":"v2/frontend/pages/admin/landing-pages-page/#overview","title":"Overview","text":"

The LandingPagesPage provides administrative management of landing pages built with GrapesJS visual editor or raw HTML/CSS code editor. It offers CRUD operations on pages with pagination, search/filter capabilities, and dual publishing modes: standalone React renderer (/p/:slug) and MkDocs integration for Material theme embedding. The page includes advanced features like MkDocs synchronization to import existing override files, validation to repair missing export files, and site building integration for SUPER_ADMIN users. Pages can be configured with SEO metadata, custom MkDocs paths, and theme customization options (hide navigation, hide TOC, full-page standalone mode).

Route: /app/pages Component: admin/src/pages/LandingPagesPage.tsx (510 lines) Auth Required: Yes (All authenticated users can view; editing requires appropriate role) Layout: AppLayout Backend Module: api/src/modules/pages/

"},{"location":"v2/frontend/pages/admin/landing-pages-page/#screenshot","title":"Screenshot","text":"

[Screenshot: LandingPagesPage with \"Landing Pages\" title on left. Right side has four buttons: \"Build Site\" (visible to SUPER_ADMIN only), \"Sync Overrides\", \"Validate Exports\", and \"Create Page\" (primary blue button). Below are two filter inputs: search bar \"Search by title or description\" and status dropdown \"Published/Draft\". Table shows columns: Title (with /p/:slug below), Editor (tag: Visual/Code), Status (tag: Published/Draft), MkDocs (path + stub path in gray), Created (date), Updated (date), Actions (Edit icon, Settings icon, Eye icon for published pages, Publish/Unpublish button, Delete icon). Pagination at bottom: \"24 pages\" with page selector.]

"},{"location":"v2/frontend/pages/admin/landing-pages-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/landing-pages-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/landing-pages-page/#creating-a-new-landing-page","title":"Creating a New Landing Page","text":"
  1. Navigate to /app/pages
  2. Click \"Create Page\" button (top-right, primary blue)
  3. Modal appears: \"Create Landing Page\"
  4. Fill in fields:
  5. Title: (required) e.g., \"About Our Campaign\"
  6. Description: (optional) e.g., \"Learn about our mission and values\"
  7. Editor Mode: (required) Choose \"Visual Editor\" or \"Code Editor\"
  8. Click \"Create & Edit\" button
  9. Page created in database (slug auto-generated from title)
  10. Navigates to /app/pages/:id/edit (page editor)
  11. Begin editing page content in chosen editor

Editor Mode Selection:

Slug Generation:

Title \"About Our Campaign\" \u2192 Slug \"about-our-campaign\"

"},{"location":"v2/frontend/pages/admin/landing-pages-page/#editing-an-existing-page","title":"Editing an Existing Page","text":"
  1. Locate page in table
  2. Click Edit icon (pencil) in Actions column
  3. Navigates to /app/pages/:id/edit
  4. Opens GrapesJS editor (visual) or Monaco editor (code)
  5. Make changes to page content
  6. Press Ctrl+S (or click Save button in editor)
  7. Changes auto-saved to database
  8. Return to page list: Click browser back button or navigate to /app/pages
"},{"location":"v2/frontend/pages/admin/landing-pages-page/#publishing-a-page","title":"Publishing a Page","text":"
  1. Locate draft page in table (Status: \"Draft\" gray tag)
  2. Click \"Publish\" button in Actions column
  3. API request updates published: true
  4. Success message: \"Page published\"
  5. Table refreshes to show Status: \"Published\" (green tag)
  6. Effects of publishing:
  7. Page becomes visible at /p/:slug (public access)
  8. If not skipped, page exported to MkDocs site as override file
  9. Page appears in MkDocs navigation (if configured)
  10. SEO metadata becomes active
"},{"location":"v2/frontend/pages/admin/landing-pages-page/#unpublishing-a-page","title":"Unpublishing a Page","text":"
  1. Locate published page in table (Status: \"Published\" green tag)
  2. Click \"Unpublish\" button in Actions column
  3. Confirmation: No confirmation dialog (immediate action)
  4. API request updates published: false
  5. Success message: \"Page unpublished\"
  6. Table refreshes to show Status: \"Draft\" (gray tag)
  7. Effects of unpublishing:
  8. Page no longer accessible at /p/:slug (404 error)
  9. MkDocs export file remains (but page not linked)
  10. Page removed from MkDocs navigation
  11. SEO metadata inactive

Use Cases for Unpublishing: - Temporarily hide page (e.g., event page after event ends) - Work on major revisions without affecting live site - Test page changes in staging before re-publishing

"},{"location":"v2/frontend/pages/admin/landing-pages-page/#viewing-a-published-page","title":"Viewing a Published Page","text":"
  1. Locate published page in table
  2. Click Eye icon in Actions column
  3. Opens page in new browser tab: /p/:slug
  4. View page as public user sees it
  5. Close tab to return to admin

Note: Eye icon only visible for published pages (unpublished pages return 404).

"},{"location":"v2/frontend/pages/admin/landing-pages-page/#configuring-page-settings","title":"Configuring Page Settings","text":"
  1. Locate page in table
  2. Click Settings icon (gear) in Actions column
  3. Modal appears: \"Page Settings\"
  4. Configure settings (see settings modal sections below)
  5. Click \"Save\" button
  6. API request updates page metadata
  7. Success message: \"Page settings updated\"
  8. Table refreshes to show updated values

Settings Modal Sections:

Basic Settings: - Title (required) - Description (optional)

SEO Settings: - SEO Title (overrides page title in tag) - SEO Description (meta description tag) - SEO Image URL (og:image for social media)</p> <p><strong>MkDocs Integration:</strong> - Skip MkDocs Export (checkbox) - Override Path (custom MkDocs path, e.g., \"about.html\") - Full page MkDocs (checkbox for standalone mode) - Hide navigation sidebar (checkbox, only if not standalone) - Hide table of contents (checkbox, only if not standalone)</p> <h3 id=\"searching-for-pages\">Searching for Pages<a class=\"headerlink\" href=\"#searching-for-pages\" title=\"Permanent link\">\u00b6</a></h3> <ol> <li>Locate <strong>search bar</strong> (below page header, left side)</li> <li>Start typing search query (e.g., \"campaign\")</li> <li>Search automatically triggers after 300ms pause (debounce)</li> <li>Table filters to show matching pages</li> <li>Matches on: page title, description</li> <li>Clear search by clicking X icon or deleting text</li> <li>Pagination resets to page 1 when search changes</li> </ol> <h3 id=\"filtering-by-status\">Filtering by Status<a class=\"headerlink\" href=\"#filtering-by-status\" title=\"Permanent link\">\u00b6</a></h3> <ol> <li>Locate <strong>Status dropdown</strong> (below page header, right of search bar)</li> <li>Click dropdown to open options:</li> <li>Published</li> <li>Draft</li> <li>Select desired status</li> <li>Table filters to show only pages with that status</li> <li>Clear filter by clicking X icon in dropdown</li> <li>Pagination resets to page 1 when filter changes</li> </ol> <h3 id=\"syncing-mkdocs-overrides\">Syncing MkDocs Overrides<a class=\"headerlink\" href=\"#syncing-mkdocs-overrides\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>What is \"Sync Overrides\"?</strong></p> <p>MkDocs sites can have custom HTML override files in <code>mkdocs/docs/overrides/</code> directory. The sync operation: - Scans <code>mkdocs/docs/overrides/</code> for <code>.html</code> files - Creates stub page records in database for files not yet tracked - Updates existing pages if override file content changed - Imports new pages that were manually added to overrides folder</p> <p><strong>When to Sync:</strong> - After manually adding HTML files to <code>mkdocs/docs/overrides/</code> - After upgrading MkDocs theme (new override templates available) - During migration from old system - Periodically to ensure database matches filesystem</p> <p><strong>Steps:</strong></p> <ol> <li>Click <strong>\"Sync Overrides\"</strong> button (top-right, next to \"Create Page\")</li> <li>Loading spinner appears on button</li> <li>Backend scans <code>mkdocs/docs/overrides/</code> directory</li> <li>Success message shows counts:</li> <li>\"Synced: 3 imported, 2 updated, 1 stubs created\"</li> <li>OR \"No new overrides to sync\" (if no changes)</li> <li>Table refreshes to show newly imported/updated pages</li> </ol> <p><strong>Result:</strong> - New pages appear in table with editor mode = CODE - Existing pages updated with latest override content - Stub pages created for overrides without page records</p> <h3 id=\"validating-mkdocs-exports\">Validating MkDocs Exports<a class=\"headerlink\" href=\"#validating-mkdocs-exports\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>What is \"Validate Exports\"?</strong></p> <p>Published pages should have corresponding override files in <code>mkdocs/docs/overrides/</code>. The validation operation: - Checks all published pages for existence of export file - Repairs missing files by re-exporting page content - Detects and reports errors (e.g., invalid HTML, write permissions)</p> <p><strong>When to Validate:</strong> - After deleting override files manually (cleanup) - After deployment (ensure all exports present) - Before building MkDocs site (catch missing files early) - Troubleshooting page display issues</p> <p><strong>Steps:</strong></p> <ol> <li>Click <strong>\"Validate Exports\"</strong> button (top-right, next to \"Sync Overrides\")</li> <li>Loading spinner appears on button</li> <li>Backend checks all published pages</li> <li>Success message shows results:</li> <li>\"Validated 24 pages: 2 repaired\" (some files missing, now fixed)</li> <li>OR \"Validated 24 pages - all OK\" (no issues found)</li> <li>OR \"Validated 24 pages: 2 repaired, 1 errors\" (some pages have unfixable errors)</li> <li>Table refreshes if any pages updated</li> </ol> <p><strong>Common Errors:</strong> - <strong>Missing export file:</strong> Page published but no override file (now repaired) - <strong>Invalid HTML:</strong> Page content has syntax errors (cannot export) - <strong>Write permissions:</strong> Cannot write to <code>mkdocs/docs/overrides/</code> (filesystem issue)</p> <h3 id=\"building-mkdocs-site-super_admin-only\">Building MkDocs Site (SUPER_ADMIN Only)<a class=\"headerlink\" href=\"#building-mkdocs-site-super_admin-only\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>What is \"Build Site\"?</strong></p> <p>MkDocs site must be built to apply changes (new pages, updated content, theme config). The build operation: - Runs <code>mkdocs build</code> command - Regenerates all HTML pages from Markdown + overrides - Updates navigation structure - Copies static assets (images, CSS, JS)</p> <p><strong>When to Build:</strong> - After publishing new pages - After updating page content - After changing MkDocs configuration - Before deploying to production</p> <p><strong>Steps:</strong></p> <ol> <li>Ensure you are SUPER_ADMIN role (button not visible to other roles)</li> <li>Click <strong>\"Build Site\"</strong> button (top-right, next to \"Sync Overrides\")</li> <li>Confirmation modal appears: \"Build MkDocs site? This will regenerate all pages and may take up to 2 minutes.\"</li> <li>Click <strong>\"Build\"</strong> to confirm (or <strong>\"Cancel\"</strong> to abort)</li> <li>Loading spinner appears on button (build in progress)</li> <li>Wait for build to complete (typically 10-30 seconds)</li> <li>Success message: \"Site built successfully\" (or error message if build failed)</li> </ol> <p><strong>Build Errors:</strong> - <strong>MkDocs not found:</strong> MkDocs container not running (start with <code>docker compose up -d mkdocs</code>) - <strong>Configuration error:</strong> <code>mkdocs.yml</code> has syntax errors - <strong>Theme error:</strong> Material theme not installed or version mismatch</p> <h3 id=\"deleting-a-page\">Deleting a Page<a class=\"headerlink\" href=\"#deleting-a-page\" title=\"Permanent link\">\u00b6</a></h3> <ol> <li>Locate page in table</li> <li>Click <strong>Delete icon</strong> (trash can, red) in Actions column</li> <li>Confirmation popconfirm appears: \"Delete this page? This action cannot be undone.\"</li> <li>Click <strong>\"OK\"</strong> to confirm (or click outside popconfirm to cancel)</li> <li>API request deletes page from database</li> <li>Success message: \"Page deleted\"</li> <li>Table refreshes to remove deleted page</li> </ol> <p><strong>Cascade Effects:</strong> - <strong>Page record:</strong> Deleted from database - <strong>Override file:</strong> Remains in <code>mkdocs/docs/overrides/</code> (manual cleanup required) - <strong>MkDocs stub:</strong> Remains in <code>mkdocs/docs/</code> (manual cleanup required) - <strong>Public URL:</strong> <code>/p/:slug</code> returns 404 Not Found</p> <p><strong>Important:</strong> Deletion is permanent. No undo functionality. Consider unpublishing instead of deleting for temporary removal.</p> <h2 id=\"component-breakdown\">Component Breakdown<a class=\"headerlink\" href=\"#component-breakdown\" title=\"Permanent link\">\u00b6</a></h2> <h3 id=\"ant-design-components-used\">Ant Design Components Used<a class=\"headerlink\" href=\"#ant-design-components-used\" title=\"Permanent link\">\u00b6</a></h3> <ul> <li><strong>Typography.Title</strong> \u2014 Page heading (\"Landing Pages\")</li> <li><strong>Row / Col</strong> \u2014 Grid layout for header and filters</li> <li><strong>Space</strong> \u2014 Button grouping</li> <li><strong>Button</strong> \u2014 Create, Sync, Validate, Build, Edit, Settings, View, Delete</li> <li><strong>Input</strong> \u2014 Search bar with SearchOutlined icon</li> <li><strong>Select</strong> \u2014 Status filter dropdown</li> <li><strong>Table</strong> \u2014 Main data table with pagination</li> <li><strong>Tag</strong> \u2014 Editor mode tags (Visual/Code), status tags (Published/Draft)</li> <li><strong>Modal</strong> \u2014 Create page modal, settings modal</li> <li><strong>Form</strong> \u2014 Create page form, settings form</li> <li><strong>Form.Item</strong> \u2014 Form field wrappers with labels</li> <li><strong>Input.TextArea</strong> \u2014 Description field (multi-line)</li> <li><strong>Radio.Group</strong> \u2014 Editor mode selector (Visual/Code buttons)</li> <li><strong>Checkbox</strong> \u2014 Settings checkboxes (skip export, hide nav, hide TOC)</li> <li><strong>Divider</strong> \u2014 Section separator in settings modal (MkDocs Integration)</li> <li><strong>Popconfirm</strong> \u2014 Delete confirmation dialog</li> <li><strong>message</strong> \u2014 Toast notifications for success/error feedback</li> </ul> <h3 id=\"table-structure\">Table Structure<a class=\"headerlink\" href=\"#table-structure\" title=\"Permanent link\">\u00b6</a></h3> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-0-1\"><a id=\"__codelineno-0-1\" name=\"__codelineno-0-1\" href=\"#__codelineno-0-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">columns</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">ColumnsType</span><span class=\"o\"><</span><span class=\"nx\">LandingPage</span><span class=\"o\">></span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"p\">[</span> </span><span id=\"__span-0-2\"><a id=\"__codelineno-0-2\" name=\"__codelineno-0-2\" href=\"#__codelineno-0-2\"></a><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-0-3\"><a id=\"__codelineno-0-3\" name=\"__codelineno-0-3\" href=\"#__codelineno-0-3\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Title'</span><span class=\"p\">,</span> </span><span id=\"__span-0-4\"><a id=\"__codelineno-0-4\" name=\"__codelineno-0-4\" href=\"#__codelineno-0-4\"></a><span class=\"w\"> </span><span class=\"nx\">dataIndex</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'title'</span><span class=\"p\">,</span> </span><span id=\"__span-0-5\"><a id=\"__codelineno-0-5\" name=\"__codelineno-0-5\" href=\"#__codelineno-0-5\"></a><span class=\"w\"> </span><span class=\"nx\">key</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'title'</span><span class=\"p\">,</span> </span><span id=\"__span-0-6\"><a id=\"__codelineno-0-6\" name=\"__codelineno-0-6\" href=\"#__codelineno-0-6\"></a><span class=\"w\"> </span><span class=\"nx\">render</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">record</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">LandingPage</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-0-7\"><a id=\"__codelineno-0-7\" name=\"__codelineno-0-7\" href=\"#__codelineno-0-7\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">div</span><span class=\"o\">></span> </span><span id=\"__span-0-8\"><a id=\"__codelineno-0-8\" name=\"__codelineno-0-8\" href=\"#__codelineno-0-8\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">span</span><span class=\"w\"> </span><span class=\"nx\">style</span><span class=\"o\">=</span><span class=\"p\">{{</span><span class=\"w\"> </span><span class=\"nx\">fontWeight</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">500</span><span class=\"w\"> </span><span class=\"p\">}}</span><span class=\"o\">></span><span class=\"p\">{</span><span class=\"nx\">title</span><span class=\"p\">}</span><span class=\"o\"><</span><span class=\"err\">/span></span> </span><span id=\"__span-0-9\"><a id=\"__codelineno-0-9\" name=\"__codelineno-0-9\" href=\"#__codelineno-0-9\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">div</span><span class=\"w\"> </span><span class=\"nx\">style</span><span class=\"o\">=</span><span class=\"p\">{{</span><span class=\"w\"> </span><span class=\"nx\">fontSize</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">12</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">color</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'rgba(255,255,255,0.45)'</span><span class=\"w\"> </span><span class=\"p\">}}</span><span class=\"o\">></span> </span><span id=\"__span-0-10\"><a id=\"__codelineno-0-10\" name=\"__codelineno-0-10\" href=\"#__codelineno-0-10\"></a><span class=\"w\"> </span><span class=\"sr\">/p/</span><span class=\"p\">{</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">slug</span><span class=\"p\">}</span> </span><span id=\"__span-0-11\"><a id=\"__codelineno-0-11\" name=\"__codelineno-0-11\" href=\"#__codelineno-0-11\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/div></span> </span><span id=\"__span-0-12\"><a id=\"__codelineno-0-12\" name=\"__codelineno-0-12\" href=\"#__codelineno-0-12\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/div></span> </span><span id=\"__span-0-13\"><a id=\"__codelineno-0-13\" name=\"__codelineno-0-13\" href=\"#__codelineno-0-13\"></a><span class=\"w\"> </span><span class=\"p\">),</span> </span><span id=\"__span-0-14\"><a id=\"__codelineno-0-14\" name=\"__codelineno-0-14\" href=\"#__codelineno-0-14\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-0-15\"><a id=\"__codelineno-0-15\" name=\"__codelineno-0-15\" href=\"#__codelineno-0-15\"></a><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-0-16\"><a id=\"__codelineno-0-16\" name=\"__codelineno-0-16\" href=\"#__codelineno-0-16\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Editor'</span><span class=\"p\">,</span> </span><span id=\"__span-0-17\"><a id=\"__codelineno-0-17\" name=\"__codelineno-0-17\" href=\"#__codelineno-0-17\"></a><span class=\"w\"> </span><span class=\"nx\">dataIndex</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'editorMode'</span><span class=\"p\">,</span> </span><span id=\"__span-0-18\"><a id=\"__codelineno-0-18\" name=\"__codelineno-0-18\" href=\"#__codelineno-0-18\"></a><span class=\"w\"> </span><span class=\"nx\">key</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'editorMode'</span><span class=\"p\">,</span> </span><span id=\"__span-0-19\"><a id=\"__codelineno-0-19\" name=\"__codelineno-0-19\" href=\"#__codelineno-0-19\"></a><span class=\"w\"> </span><span class=\"nx\">render</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">mode</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">EditorMode</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-0-20\"><a id=\"__codelineno-0-20\" name=\"__codelineno-0-20\" href=\"#__codelineno-0-20\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Tag</span><span class=\"w\"> </span><span class=\"nx\">color</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">mode</span><span class=\"w\"> </span><span class=\"o\">===</span><span class=\"w\"> </span><span class=\"s1\">'VISUAL'</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'green'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'blue'</span><span class=\"p\">}</span><span class=\"o\">></span> </span><span id=\"__span-0-21\"><a id=\"__codelineno-0-21\" name=\"__codelineno-0-21\" href=\"#__codelineno-0-21\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"nx\">mode</span><span class=\"w\"> </span><span class=\"o\">===</span><span class=\"w\"> </span><span class=\"s1\">'VISUAL'</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'Visual'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Code'</span><span class=\"p\">}</span> </span><span id=\"__span-0-22\"><a id=\"__codelineno-0-22\" name=\"__codelineno-0-22\" href=\"#__codelineno-0-22\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Tag></span> </span><span id=\"__span-0-23\"><a id=\"__codelineno-0-23\" name=\"__codelineno-0-23\" href=\"#__codelineno-0-23\"></a><span class=\"w\"> </span><span class=\"p\">),</span> </span><span id=\"__span-0-24\"><a id=\"__codelineno-0-24\" name=\"__codelineno-0-24\" href=\"#__codelineno-0-24\"></a><span class=\"w\"> </span><span class=\"nx\">responsive</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"s1\">'sm'</span><span class=\"p\">],</span><span class=\"w\"> </span><span class=\"c1\">// Hidden on mobile</span> </span><span id=\"__span-0-25\"><a id=\"__codelineno-0-25\" name=\"__codelineno-0-25\" href=\"#__codelineno-0-25\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-0-26\"><a id=\"__codelineno-0-26\" name=\"__codelineno-0-26\" href=\"#__codelineno-0-26\"></a><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-0-27\"><a id=\"__codelineno-0-27\" name=\"__codelineno-0-27\" href=\"#__codelineno-0-27\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Status'</span><span class=\"p\">,</span> </span><span id=\"__span-0-28\"><a id=\"__codelineno-0-28\" name=\"__codelineno-0-28\" href=\"#__codelineno-0-28\"></a><span class=\"w\"> </span><span class=\"nx\">dataIndex</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'published'</span><span class=\"p\">,</span> </span><span id=\"__span-0-29\"><a id=\"__codelineno-0-29\" name=\"__codelineno-0-29\" href=\"#__codelineno-0-29\"></a><span class=\"w\"> </span><span class=\"nx\">key</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'published'</span><span class=\"p\">,</span> </span><span id=\"__span-0-30\"><a id=\"__codelineno-0-30\" name=\"__codelineno-0-30\" href=\"#__codelineno-0-30\"></a><span class=\"w\"> </span><span class=\"nx\">render</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">published</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">boolean</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-0-31\"><a id=\"__codelineno-0-31\" name=\"__codelineno-0-31\" href=\"#__codelineno-0-31\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Tag</span><span class=\"w\"> </span><span class=\"nx\">color</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'green'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'default'</span><span class=\"p\">}</span><span class=\"o\">></span> </span><span id=\"__span-0-32\"><a id=\"__codelineno-0-32\" name=\"__codelineno-0-32\" href=\"#__codelineno-0-32\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'Published'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Draft'</span><span class=\"p\">}</span> </span><span id=\"__span-0-33\"><a id=\"__codelineno-0-33\" name=\"__codelineno-0-33\" href=\"#__codelineno-0-33\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Tag></span> </span><span id=\"__span-0-34\"><a id=\"__codelineno-0-34\" name=\"__codelineno-0-34\" href=\"#__codelineno-0-34\"></a><span class=\"w\"> </span><span class=\"p\">),</span> </span><span id=\"__span-0-35\"><a id=\"__codelineno-0-35\" name=\"__codelineno-0-35\" href=\"#__codelineno-0-35\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-0-36\"><a id=\"__codelineno-0-36\" name=\"__codelineno-0-36\" href=\"#__codelineno-0-36\"></a><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-0-37\"><a id=\"__codelineno-0-37\" name=\"__codelineno-0-37\" href=\"#__codelineno-0-37\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'MkDocs'</span><span class=\"p\">,</span> </span><span id=\"__span-0-38\"><a id=\"__codelineno-0-38\" name=\"__codelineno-0-38\" href=\"#__codelineno-0-38\"></a><span class=\"w\"> </span><span class=\"nx\">dataIndex</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'mkdocsPath'</span><span class=\"p\">,</span> </span><span id=\"__span-0-39\"><a id=\"__codelineno-0-39\" name=\"__codelineno-0-39\" href=\"#__codelineno-0-39\"></a><span class=\"w\"> </span><span class=\"nx\">key</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'mkdocsPath'</span><span class=\"p\">,</span> </span><span id=\"__span-0-40\"><a id=\"__codelineno-0-40\" name=\"__codelineno-0-40\" href=\"#__codelineno-0-40\"></a><span class=\"w\"> </span><span class=\"nx\">render</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">_</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"w\"> </span><span class=\"o\">|</span><span class=\"w\"> </span><span class=\"kc\">null</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">record</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">LandingPage</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-0-41\"><a id=\"__codelineno-0-41\" name=\"__codelineno-0-41\" href=\"#__codelineno-0-41\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">div</span><span class=\"o\">></span> </span><span id=\"__span-0-42\"><a id=\"__codelineno-0-42\" name=\"__codelineno-0-42\" href=\"#__codelineno-0-42\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">div</span><span class=\"o\">></span><span class=\"p\">{</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">mkdocsPath</span><span class=\"w\"> </span><span class=\"o\">||</span><span class=\"w\"> </span><span class=\"s1\">'--'</span><span class=\"p\">}</span><span class=\"o\"><</span><span class=\"err\">/div></span> </span><span id=\"__span-0-43\"><a id=\"__codelineno-0-43\" name=\"__codelineno-0-43\" href=\"#__codelineno-0-43\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">mkdocsStubPath</span><span class=\"w\"> </span><span class=\"o\">&&</span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-0-44\"><a id=\"__codelineno-0-44\" name=\"__codelineno-0-44\" href=\"#__codelineno-0-44\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">div</span><span class=\"w\"> </span><span class=\"nx\">style</span><span class=\"o\">=</span><span class=\"p\">{{</span><span class=\"w\"> </span><span class=\"nx\">fontSize</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">11</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">color</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'rgba(255,255,255,0.45)'</span><span class=\"w\"> </span><span class=\"p\">}}</span><span class=\"o\">></span> </span><span id=\"__span-0-45\"><a id=\"__codelineno-0-45\" name=\"__codelineno-0-45\" href=\"#__codelineno-0-45\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">mkdocsStubPath</span><span class=\"p\">}</span> </span><span id=\"__span-0-46\"><a id=\"__codelineno-0-46\" name=\"__codelineno-0-46\" href=\"#__codelineno-0-46\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/div></span> </span><span id=\"__span-0-47\"><a id=\"__codelineno-0-47\" name=\"__codelineno-0-47\" href=\"#__codelineno-0-47\"></a><span class=\"w\"> </span><span class=\"p\">)}</span> </span><span id=\"__span-0-48\"><a id=\"__codelineno-0-48\" name=\"__codelineno-0-48\" href=\"#__codelineno-0-48\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/div></span> </span><span id=\"__span-0-49\"><a id=\"__codelineno-0-49\" name=\"__codelineno-0-49\" href=\"#__codelineno-0-49\"></a><span class=\"w\"> </span><span class=\"p\">),</span> </span><span id=\"__span-0-50\"><a id=\"__codelineno-0-50\" name=\"__codelineno-0-50\" href=\"#__codelineno-0-50\"></a><span class=\"w\"> </span><span class=\"nx\">responsive</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"s1\">'lg'</span><span class=\"p\">],</span><span class=\"w\"> </span><span class=\"c1\">// Hidden on mobile/tablet</span> </span><span id=\"__span-0-51\"><a id=\"__codelineno-0-51\" name=\"__codelineno-0-51\" href=\"#__codelineno-0-51\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-0-52\"><a id=\"__codelineno-0-52\" name=\"__codelineno-0-52\" href=\"#__codelineno-0-52\"></a><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-0-53\"><a id=\"__codelineno-0-53\" name=\"__codelineno-0-53\" href=\"#__codelineno-0-53\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Created'</span><span class=\"p\">,</span> </span><span id=\"__span-0-54\"><a id=\"__codelineno-0-54\" name=\"__codelineno-0-54\" href=\"#__codelineno-0-54\"></a><span class=\"w\"> </span><span class=\"nx\">dataIndex</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'createdAt'</span><span class=\"p\">,</span> </span><span id=\"__span-0-55\"><a id=\"__codelineno-0-55\" name=\"__codelineno-0-55\" href=\"#__codelineno-0-55\"></a><span class=\"w\"> </span><span class=\"nx\">key</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'createdAt'</span><span class=\"p\">,</span> </span><span id=\"__span-0-56\"><a id=\"__codelineno-0-56\" name=\"__codelineno-0-56\" href=\"#__codelineno-0-56\"></a><span class=\"w\"> </span><span class=\"nx\">render</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">date</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">dayjs</span><span class=\"p\">(</span><span class=\"nx\">date</span><span class=\"p\">).</span><span class=\"nx\">format</span><span class=\"p\">(</span><span class=\"s1\">'YYYY-MM-DD'</span><span class=\"p\">),</span> </span><span id=\"__span-0-57\"><a id=\"__codelineno-0-57\" name=\"__codelineno-0-57\" href=\"#__codelineno-0-57\"></a><span class=\"w\"> </span><span class=\"nx\">responsive</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"s1\">'md'</span><span class=\"p\">],</span><span class=\"w\"> </span><span class=\"c1\">// Hidden on mobile</span> </span><span id=\"__span-0-58\"><a id=\"__codelineno-0-58\" name=\"__codelineno-0-58\" href=\"#__codelineno-0-58\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-0-59\"><a id=\"__codelineno-0-59\" name=\"__codelineno-0-59\" href=\"#__codelineno-0-59\"></a><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-0-60\"><a id=\"__codelineno-0-60\" name=\"__codelineno-0-60\" href=\"#__codelineno-0-60\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Updated'</span><span class=\"p\">,</span> </span><span id=\"__span-0-61\"><a id=\"__codelineno-0-61\" name=\"__codelineno-0-61\" href=\"#__codelineno-0-61\"></a><span class=\"w\"> </span><span class=\"nx\">dataIndex</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'updatedAt'</span><span class=\"p\">,</span> </span><span id=\"__span-0-62\"><a id=\"__codelineno-0-62\" name=\"__codelineno-0-62\" href=\"#__codelineno-0-62\"></a><span class=\"w\"> </span><span class=\"nx\">key</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'updatedAt'</span><span class=\"p\">,</span> </span><span id=\"__span-0-63\"><a id=\"__codelineno-0-63\" name=\"__codelineno-0-63\" href=\"#__codelineno-0-63\"></a><span class=\"w\"> </span><span class=\"nx\">render</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">date</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">dayjs</span><span class=\"p\">(</span><span class=\"nx\">date</span><span class=\"p\">).</span><span class=\"nx\">format</span><span class=\"p\">(</span><span class=\"s1\">'YYYY-MM-DD'</span><span class=\"p\">),</span> </span><span id=\"__span-0-64\"><a id=\"__codelineno-0-64\" name=\"__codelineno-0-64\" href=\"#__codelineno-0-64\"></a><span class=\"w\"> </span><span class=\"nx\">responsive</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"s1\">'md'</span><span class=\"p\">],</span><span class=\"w\"> </span><span class=\"c1\">// Hidden on mobile</span> </span><span id=\"__span-0-65\"><a id=\"__codelineno-0-65\" name=\"__codelineno-0-65\" href=\"#__codelineno-0-65\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-0-66\"><a id=\"__codelineno-0-66\" name=\"__codelineno-0-66\" href=\"#__codelineno-0-66\"></a><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-0-67\"><a id=\"__codelineno-0-67\" name=\"__codelineno-0-67\" href=\"#__codelineno-0-67\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Actions'</span><span class=\"p\">,</span> </span><span id=\"__span-0-68\"><a id=\"__codelineno-0-68\" name=\"__codelineno-0-68\" href=\"#__codelineno-0-68\"></a><span class=\"w\"> </span><span class=\"nx\">key</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'actions'</span><span class=\"p\">,</span> </span><span id=\"__span-0-69\"><a id=\"__codelineno-0-69\" name=\"__codelineno-0-69\" href=\"#__codelineno-0-69\"></a><span class=\"w\"> </span><span class=\"nx\">render</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">_</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">unknown</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">record</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">LandingPage</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-0-70\"><a id=\"__codelineno-0-70\" name=\"__codelineno-0-70\" href=\"#__codelineno-0-70\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Space</span><span class=\"o\">></span> </span><span id=\"__span-0-71\"><a id=\"__codelineno-0-71\" name=\"__codelineno-0-71\" href=\"#__codelineno-0-71\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Button</span> </span><span id=\"__span-0-72\"><a id=\"__codelineno-0-72\" name=\"__codelineno-0-72\" href=\"#__codelineno-0-72\"></a><span class=\"w\"> </span><span class=\"kr\">type</span><span class=\"o\">=</span><span class=\"s2\">\"link\"</span> </span><span id=\"__span-0-73\"><a id=\"__codelineno-0-73\" name=\"__codelineno-0-73\" href=\"#__codelineno-0-73\"></a><span class=\"w\"> </span><span class=\"nx\">size</span><span class=\"o\">=</span><span class=\"s2\">\"small\"</span> </span><span id=\"__span-0-74\"><a id=\"__codelineno-0-74\" name=\"__codelineno-0-74\" href=\"#__codelineno-0-74\"></a><span class=\"w\"> </span><span class=\"nx\">icon</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"o\"><</span><span class=\"nx\">EditOutlined</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span> </span><span id=\"__span-0-75\"><a id=\"__codelineno-0-75\" name=\"__codelineno-0-75\" href=\"#__codelineno-0-75\"></a><span class=\"w\"> </span><span class=\"nx\">onClick</span><span class=\"o\">=</span><span class=\"p\">{()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">navigate</span><span class=\"p\">(</span><span class=\"sb\">`/app/pages/</span><span class=\"si\">${</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">id</span><span class=\"si\">}</span><span class=\"sb\">/edit`</span><span class=\"p\">)}</span> </span><span id=\"__span-0-76\"><a id=\"__codelineno-0-76\" name=\"__codelineno-0-76\" href=\"#__codelineno-0-76\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">editorMode</span><span class=\"w\"> </span><span class=\"o\">===</span><span class=\"w\"> </span><span class=\"s1\">'CODE'</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'Edit code'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Edit in builder'</span><span class=\"p\">}</span> </span><span id=\"__span-0-77\"><a id=\"__codelineno-0-77\" name=\"__codelineno-0-77\" href=\"#__codelineno-0-77\"></a><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-0-78\"><a id=\"__codelineno-0-78\" name=\"__codelineno-0-78\" href=\"#__codelineno-0-78\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Button</span> </span><span id=\"__span-0-79\"><a id=\"__codelineno-0-79\" name=\"__codelineno-0-79\" href=\"#__codelineno-0-79\"></a><span class=\"w\"> </span><span class=\"kr\">type</span><span class=\"o\">=</span><span class=\"s2\">\"link\"</span> </span><span id=\"__span-0-80\"><a id=\"__codelineno-0-80\" name=\"__codelineno-0-80\" href=\"#__codelineno-0-80\"></a><span class=\"w\"> </span><span class=\"nx\">size</span><span class=\"o\">=</span><span class=\"s2\">\"small\"</span> </span><span id=\"__span-0-81\"><a id=\"__codelineno-0-81\" name=\"__codelineno-0-81\" href=\"#__codelineno-0-81\"></a><span class=\"w\"> </span><span class=\"nx\">icon</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"o\"><</span><span class=\"nx\">SettingOutlined</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span> </span><span id=\"__span-0-82\"><a id=\"__codelineno-0-82\" name=\"__codelineno-0-82\" href=\"#__codelineno-0-82\"></a><span class=\"w\"> </span><span class=\"nx\">onClick</span><span class=\"o\">=</span><span class=\"p\">{()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">openSettings</span><span class=\"p\">(</span><span class=\"nx\">record</span><span class=\"p\">)}</span> </span><span id=\"__span-0-83\"><a id=\"__codelineno-0-83\" name=\"__codelineno-0-83\" href=\"#__codelineno-0-83\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">=</span><span class=\"s2\">\"Page settings\"</span> </span><span id=\"__span-0-84\"><a id=\"__codelineno-0-84\" name=\"__codelineno-0-84\" href=\"#__codelineno-0-84\"></a><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-0-85\"><a id=\"__codelineno-0-85\" name=\"__codelineno-0-85\" href=\"#__codelineno-0-85\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">&&</span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-0-86\"><a id=\"__codelineno-0-86\" name=\"__codelineno-0-86\" href=\"#__codelineno-0-86\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Button</span> </span><span id=\"__span-0-87\"><a id=\"__codelineno-0-87\" name=\"__codelineno-0-87\" href=\"#__codelineno-0-87\"></a><span class=\"w\"> </span><span class=\"kr\">type</span><span class=\"o\">=</span><span class=\"s2\">\"link\"</span> </span><span id=\"__span-0-88\"><a id=\"__codelineno-0-88\" name=\"__codelineno-0-88\" href=\"#__codelineno-0-88\"></a><span class=\"w\"> </span><span class=\"nx\">size</span><span class=\"o\">=</span><span class=\"s2\">\"small\"</span> </span><span id=\"__span-0-89\"><a id=\"__codelineno-0-89\" name=\"__codelineno-0-89\" href=\"#__codelineno-0-89\"></a><span class=\"w\"> </span><span class=\"nx\">icon</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"o\"><</span><span class=\"nx\">EyeOutlined</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span> </span><span id=\"__span-0-90\"><a id=\"__codelineno-0-90\" name=\"__codelineno-0-90\" href=\"#__codelineno-0-90\"></a><span class=\"w\"> </span><span class=\"nx\">onClick</span><span class=\"o\">=</span><span class=\"p\">{()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nb\">window</span><span class=\"p\">.</span><span class=\"nx\">open</span><span class=\"p\">(</span><span class=\"sb\">`/p/</span><span class=\"si\">${</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">slug</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"s1\">'_blank'</span><span class=\"p\">)}</span> </span><span id=\"__span-0-91\"><a id=\"__codelineno-0-91\" name=\"__codelineno-0-91\" href=\"#__codelineno-0-91\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">=</span><span class=\"s2\">\"View page\"</span> </span><span id=\"__span-0-92\"><a id=\"__codelineno-0-92\" name=\"__codelineno-0-92\" href=\"#__codelineno-0-92\"></a><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-0-93\"><a id=\"__codelineno-0-93\" name=\"__codelineno-0-93\" href=\"#__codelineno-0-93\"></a><span class=\"w\"> </span><span class=\"p\">)}</span> </span><span id=\"__span-0-94\"><a id=\"__codelineno-0-94\" name=\"__codelineno-0-94\" href=\"#__codelineno-0-94\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Button</span> </span><span id=\"__span-0-95\"><a id=\"__codelineno-0-95\" name=\"__codelineno-0-95\" href=\"#__codelineno-0-95\"></a><span class=\"w\"> </span><span class=\"kr\">type</span><span class=\"o\">=</span><span class=\"s2\">\"link\"</span> </span><span id=\"__span-0-96\"><a id=\"__codelineno-0-96\" name=\"__codelineno-0-96\" href=\"#__codelineno-0-96\"></a><span class=\"w\"> </span><span class=\"nx\">size</span><span class=\"o\">=</span><span class=\"s2\">\"small\"</span> </span><span id=\"__span-0-97\"><a id=\"__codelineno-0-97\" name=\"__codelineno-0-97\" href=\"#__codelineno-0-97\"></a><span class=\"w\"> </span><span class=\"nx\">onClick</span><span class=\"o\">=</span><span class=\"p\">{()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">handleTogglePublished</span><span class=\"p\">(</span><span class=\"nx\">record</span><span class=\"p\">)}</span> </span><span id=\"__span-0-98\"><a id=\"__codelineno-0-98\" name=\"__codelineno-0-98\" href=\"#__codelineno-0-98\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'Unpublish'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Publish'</span><span class=\"p\">}</span> </span><span id=\"__span-0-99\"><a id=\"__codelineno-0-99\" name=\"__codelineno-0-99\" href=\"#__codelineno-0-99\"></a><span class=\"w\"> </span><span class=\"o\">></span> </span><span id=\"__span-0-100\"><a id=\"__codelineno-0-100\" name=\"__codelineno-0-100\" href=\"#__codelineno-0-100\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'Unpublish'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Publish'</span><span class=\"p\">}</span> </span><span id=\"__span-0-101\"><a id=\"__codelineno-0-101\" name=\"__codelineno-0-101\" href=\"#__codelineno-0-101\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Button></span> </span><span id=\"__span-0-102\"><a id=\"__codelineno-0-102\" name=\"__codelineno-0-102\" href=\"#__codelineno-0-102\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Popconfirm</span> </span><span id=\"__span-0-103\"><a id=\"__codelineno-0-103\" name=\"__codelineno-0-103\" href=\"#__codelineno-0-103\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">=</span><span class=\"s2\">\"Delete this page?\"</span> </span><span id=\"__span-0-104\"><a id=\"__codelineno-0-104\" name=\"__codelineno-0-104\" href=\"#__codelineno-0-104\"></a><span class=\"w\"> </span><span class=\"nx\">description</span><span class=\"o\">=</span><span class=\"s2\">\"This action cannot be undone.\"</span> </span><span id=\"__span-0-105\"><a id=\"__codelineno-0-105\" name=\"__codelineno-0-105\" href=\"#__codelineno-0-105\"></a><span class=\"w\"> </span><span class=\"nx\">onConfirm</span><span class=\"o\">=</span><span class=\"p\">{()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">handleDelete</span><span class=\"p\">(</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">id</span><span class=\"p\">)}</span> </span><span id=\"__span-0-106\"><a id=\"__codelineno-0-106\" name=\"__codelineno-0-106\" href=\"#__codelineno-0-106\"></a><span class=\"w\"> </span><span class=\"o\">></span> </span><span id=\"__span-0-107\"><a id=\"__codelineno-0-107\" name=\"__codelineno-0-107\" href=\"#__codelineno-0-107\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Button</span><span class=\"w\"> </span><span class=\"kr\">type</span><span class=\"o\">=</span><span class=\"s2\">\"link\"</span><span class=\"w\"> </span><span class=\"nx\">size</span><span class=\"o\">=</span><span class=\"s2\">\"small\"</span><span class=\"w\"> </span><span class=\"nx\">danger</span><span class=\"w\"> </span><span class=\"nx\">icon</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"o\"><</span><span class=\"nx\">DeleteOutlined</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">=</span><span class=\"s2\">\"Delete\"</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-0-108\"><a id=\"__codelineno-0-108\" name=\"__codelineno-0-108\" href=\"#__codelineno-0-108\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Popconfirm></span> </span><span id=\"__span-0-109\"><a id=\"__codelineno-0-109\" name=\"__codelineno-0-109\" href=\"#__codelineno-0-109\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Space></span> </span><span id=\"__span-0-110\"><a id=\"__codelineno-0-110\" name=\"__codelineno-0-110\" href=\"#__codelineno-0-110\"></a><span class=\"w\"> </span><span class=\"p\">),</span> </span><span id=\"__span-0-111\"><a id=\"__codelineno-0-111\" name=\"__codelineno-0-111\" href=\"#__codelineno-0-111\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-0-112\"><a id=\"__codelineno-0-112\" name=\"__codelineno-0-112\" href=\"#__codelineno-0-112\"></a><span class=\"p\">];</span> </span></code></pre></div> <p><strong>Column Features:</strong> - <strong>Title:</strong> Primary identifier with <code>/p/:slug</code> shown below (small gray text) - <strong>Editor:</strong> Color-coded tag (Visual=green, Code=blue), hidden on mobile - <strong>Status:</strong> Color-coded tag (Published=green, Draft=gray) - <strong>MkDocs:</strong> Shows mkdocsPath (custom path) and mkdocsStubPath (Markdown stub path) in gray, hidden on mobile/tablet - <strong>Created:</strong> Date only (YYYY-MM-DD format), hidden on mobile - <strong>Updated:</strong> Date only (YYYY-MM-DD format), hidden on mobile - <strong>Actions:</strong> 5 buttons (Edit, Settings, View [if published], Publish/Unpublish, Delete)</p> <h3 id=\"create-page-modal\">Create Page Modal<a class=\"headerlink\" href=\"#create-page-modal\" title=\"Permanent link\">\u00b6</a></h3> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-1-1\"><a id=\"__codelineno-1-1\" name=\"__codelineno-1-1\" href=\"#__codelineno-1-1\"></a><span class=\"o\"><</span><span class=\"nx\">Modal</span> </span><span id=\"__span-1-2\"><a id=\"__codelineno-1-2\" name=\"__codelineno-1-2\" href=\"#__codelineno-1-2\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">=</span><span class=\"s2\">\"Create Landing Page\"</span> </span><span id=\"__span-1-3\"><a id=\"__codelineno-1-3\" name=\"__codelineno-1-3\" href=\"#__codelineno-1-3\"></a><span class=\"w\"> </span><span class=\"nx\">open</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">createModalOpen</span><span class=\"p\">}</span> </span><span id=\"__span-1-4\"><a id=\"__codelineno-1-4\" name=\"__codelineno-1-4\" href=\"#__codelineno-1-4\"></a><span class=\"w\"> </span><span class=\"nx\">destroyOnHidden</span> </span><span id=\"__span-1-5\"><a id=\"__codelineno-1-5\" name=\"__codelineno-1-5\" href=\"#__codelineno-1-5\"></a><span class=\"w\"> </span><span class=\"nx\">onCancel</span><span class=\"o\">=</span><span class=\"p\">{()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-1-6\"><a id=\"__codelineno-1-6\" name=\"__codelineno-1-6\" href=\"#__codelineno-1-6\"></a><span class=\"w\"> </span><span class=\"nx\">setCreateModalOpen</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-1-7\"><a id=\"__codelineno-1-7\" name=\"__codelineno-1-7\" href=\"#__codelineno-1-7\"></a><span class=\"w\"> </span><span class=\"nx\">createForm</span><span class=\"p\">.</span><span class=\"nx\">resetFields</span><span class=\"p\">();</span> </span><span id=\"__span-1-8\"><a id=\"__codelineno-1-8\" name=\"__codelineno-1-8\" href=\"#__codelineno-1-8\"></a><span class=\"w\"> </span><span class=\"p\">}}</span> </span><span id=\"__span-1-9\"><a id=\"__codelineno-1-9\" name=\"__codelineno-1-9\" href=\"#__codelineno-1-9\"></a><span class=\"w\"> </span><span class=\"nx\">onOk</span><span class=\"o\">=</span><span class=\"p\">{()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">createForm</span><span class=\"p\">.</span><span class=\"nx\">submit</span><span class=\"p\">()}</span> </span><span id=\"__span-1-10\"><a id=\"__codelineno-1-10\" name=\"__codelineno-1-10\" href=\"#__codelineno-1-10\"></a><span class=\"w\"> </span><span class=\"nx\">okText</span><span class=\"o\">=</span><span class=\"s2\">\"Create & Edit\"</span> </span><span id=\"__span-1-11\"><a id=\"__codelineno-1-11\" name=\"__codelineno-1-11\" href=\"#__codelineno-1-11\"></a><span class=\"o\">></span> </span><span id=\"__span-1-12\"><a id=\"__codelineno-1-12\" name=\"__codelineno-1-12\" href=\"#__codelineno-1-12\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"w\"> </span><span class=\"nx\">form</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">createForm</span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"nx\">onFinish</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">handleCreate</span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"nx\">layout</span><span class=\"o\">=</span><span class=\"s2\">\"vertical\"</span><span class=\"w\"> </span><span class=\"nx\">initialValues</span><span class=\"o\">=</span><span class=\"p\">{{</span><span class=\"w\"> </span><span class=\"nx\">editorMode</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'VISUAL'</span><span class=\"w\"> </span><span class=\"p\">}}</span><span class=\"o\">></span> </span><span id=\"__span-1-13\"><a id=\"__codelineno-1-13\" name=\"__codelineno-1-13\" href=\"#__codelineno-1-13\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span> </span><span id=\"__span-1-14\"><a id=\"__codelineno-1-14\" name=\"__codelineno-1-14\" href=\"#__codelineno-1-14\"></a><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"title\"</span> </span><span id=\"__span-1-15\"><a id=\"__codelineno-1-15\" name=\"__codelineno-1-15\" href=\"#__codelineno-1-15\"></a><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"Title\"</span> </span><span id=\"__span-1-16\"><a id=\"__codelineno-1-16\" name=\"__codelineno-1-16\" href=\"#__codelineno-1-16\"></a><span class=\"w\"> </span><span class=\"nx\">rules</span><span class=\"o\">=</span><span class=\"p\">{[{</span><span class=\"w\"> </span><span class=\"nx\">required</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">true</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Title is required'</span><span class=\"w\"> </span><span class=\"p\">}]}</span> </span><span id=\"__span-1-17\"><a id=\"__codelineno-1-17\" name=\"__codelineno-1-17\" href=\"#__codelineno-1-17\"></a><span class=\"w\"> </span><span class=\"o\">></span> </span><span id=\"__span-1-18\"><a id=\"__codelineno-1-18\" name=\"__codelineno-1-18\" href=\"#__codelineno-1-18\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Input</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-1-19\"><a id=\"__codelineno-1-19\" name=\"__codelineno-1-19\" href=\"#__codelineno-1-19\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-1-20\"><a id=\"__codelineno-1-20\" name=\"__codelineno-1-20\" href=\"#__codelineno-1-20\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"description\"</span><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"Description\"</span><span class=\"o\">></span> </span><span id=\"__span-1-21\"><a id=\"__codelineno-1-21\" name=\"__codelineno-1-21\" href=\"#__codelineno-1-21\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">TextArea</span><span class=\"w\"> </span><span class=\"nx\">rows</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"mf\">3</span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-1-22\"><a id=\"__codelineno-1-22\" name=\"__codelineno-1-22\" href=\"#__codelineno-1-22\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-1-23\"><a id=\"__codelineno-1-23\" name=\"__codelineno-1-23\" href=\"#__codelineno-1-23\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"editorMode\"</span><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"Editor Mode\"</span><span class=\"o\">></span> </span><span id=\"__span-1-24\"><a id=\"__codelineno-1-24\" name=\"__codelineno-1-24\" href=\"#__codelineno-1-24\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Radio</span><span class=\"p\">.</span><span class=\"nx\">Group</span><span class=\"o\">></span> </span><span id=\"__span-1-25\"><a id=\"__codelineno-1-25\" name=\"__codelineno-1-25\" href=\"#__codelineno-1-25\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Radio</span><span class=\"p\">.</span><span class=\"nx\">Button</span><span class=\"w\"> </span><span class=\"nx\">value</span><span class=\"o\">=</span><span class=\"s2\">\"VISUAL\"</span><span class=\"o\">></span><span class=\"nx\">Visual</span><span class=\"w\"> </span><span class=\"nx\">Editor</span><span class=\"o\"><</span><span class=\"err\">/Radio.Button></span> </span><span id=\"__span-1-26\"><a id=\"__codelineno-1-26\" name=\"__codelineno-1-26\" href=\"#__codelineno-1-26\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Radio</span><span class=\"p\">.</span><span class=\"nx\">Button</span><span class=\"w\"> </span><span class=\"nx\">value</span><span class=\"o\">=</span><span class=\"s2\">\"CODE\"</span><span class=\"o\">></span><span class=\"nx\">Code</span><span class=\"w\"> </span><span class=\"nx\">Editor</span><span class=\"o\"><</span><span class=\"err\">/Radio.Button></span> </span><span id=\"__span-1-27\"><a id=\"__codelineno-1-27\" name=\"__codelineno-1-27\" href=\"#__codelineno-1-27\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Radio.Group></span> </span><span id=\"__span-1-28\"><a id=\"__codelineno-1-28\" name=\"__codelineno-1-28\" href=\"#__codelineno-1-28\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-1-29\"><a id=\"__codelineno-1-29\" name=\"__codelineno-1-29\" href=\"#__codelineno-1-29\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form></span> </span><span id=\"__span-1-30\"><a id=\"__codelineno-1-30\" name=\"__codelineno-1-30\" href=\"#__codelineno-1-30\"></a><span class=\"o\"><</span><span class=\"err\">/Modal></span> </span></code></pre></div> <p><strong>Modal Features:</strong> - <strong>destroyOnHidden:</strong> Form resets when modal closes (no stale data) - <strong>okText:</strong> \"Create & Edit\" (clarifies that page will open in editor after creation) - <strong>Initial values:</strong> Editor mode defaults to VISUAL (most users prefer visual editor) - <strong>Validation:</strong> Title required (cannot be empty)</p> <h3 id=\"settings-modal\">Settings Modal<a class=\"headerlink\" href=\"#settings-modal\" title=\"Permanent link\">\u00b6</a></h3> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-2-1\"><a id=\"__codelineno-2-1\" name=\"__codelineno-2-1\" href=\"#__codelineno-2-1\"></a><span class=\"o\"><</span><span class=\"nx\">Modal</span> </span><span id=\"__span-2-2\"><a id=\"__codelineno-2-2\" name=\"__codelineno-2-2\" href=\"#__codelineno-2-2\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">=</span><span class=\"s2\">\"Page Settings\"</span> </span><span id=\"__span-2-3\"><a id=\"__codelineno-2-3\" name=\"__codelineno-2-3\" href=\"#__codelineno-2-3\"></a><span class=\"w\"> </span><span class=\"nx\">open</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">settingsModalOpen</span><span class=\"p\">}</span> </span><span id=\"__span-2-4\"><a id=\"__codelineno-2-4\" name=\"__codelineno-2-4\" href=\"#__codelineno-2-4\"></a><span class=\"w\"> </span><span class=\"nx\">destroyOnHidden</span> </span><span id=\"__span-2-5\"><a id=\"__codelineno-2-5\" name=\"__codelineno-2-5\" href=\"#__codelineno-2-5\"></a><span class=\"w\"> </span><span class=\"nx\">width</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"mf\">560</span><span class=\"p\">}</span> </span><span id=\"__span-2-6\"><a id=\"__codelineno-2-6\" name=\"__codelineno-2-6\" href=\"#__codelineno-2-6\"></a><span class=\"w\"> </span><span class=\"nx\">onCancel</span><span class=\"o\">=</span><span class=\"p\">{()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-2-7\"><a id=\"__codelineno-2-7\" name=\"__codelineno-2-7\" href=\"#__codelineno-2-7\"></a><span class=\"w\"> </span><span class=\"nx\">setSettingsModalOpen</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-2-8\"><a id=\"__codelineno-2-8\" name=\"__codelineno-2-8\" href=\"#__codelineno-2-8\"></a><span class=\"w\"> </span><span class=\"nx\">setEditingPage</span><span class=\"p\">(</span><span class=\"kc\">null</span><span class=\"p\">);</span> </span><span id=\"__span-2-9\"><a id=\"__codelineno-2-9\" name=\"__codelineno-2-9\" href=\"#__codelineno-2-9\"></a><span class=\"w\"> </span><span class=\"nx\">settingsForm</span><span class=\"p\">.</span><span class=\"nx\">resetFields</span><span class=\"p\">();</span> </span><span id=\"__span-2-10\"><a id=\"__codelineno-2-10\" name=\"__codelineno-2-10\" href=\"#__codelineno-2-10\"></a><span class=\"w\"> </span><span class=\"p\">}}</span> </span><span id=\"__span-2-11\"><a id=\"__codelineno-2-11\" name=\"__codelineno-2-11\" href=\"#__codelineno-2-11\"></a><span class=\"w\"> </span><span class=\"nx\">onOk</span><span class=\"o\">=</span><span class=\"p\">{()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">settingsForm</span><span class=\"p\">.</span><span class=\"nx\">submit</span><span class=\"p\">()}</span> </span><span id=\"__span-2-12\"><a id=\"__codelineno-2-12\" name=\"__codelineno-2-12\" href=\"#__codelineno-2-12\"></a><span class=\"w\"> </span><span class=\"nx\">okText</span><span class=\"o\">=</span><span class=\"s2\">\"Save\"</span> </span><span id=\"__span-2-13\"><a id=\"__codelineno-2-13\" name=\"__codelineno-2-13\" href=\"#__codelineno-2-13\"></a><span class=\"o\">></span> </span><span id=\"__span-2-14\"><a id=\"__codelineno-2-14\" name=\"__codelineno-2-14\" href=\"#__codelineno-2-14\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"w\"> </span><span class=\"nx\">form</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">settingsForm</span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"nx\">onFinish</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">handleSettingsSave</span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"nx\">layout</span><span class=\"o\">=</span><span class=\"s2\">\"vertical\"</span><span class=\"o\">></span> </span><span id=\"__span-2-15\"><a id=\"__codelineno-2-15\" name=\"__codelineno-2-15\" href=\"#__codelineno-2-15\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"cm\">/* Basic Settings */</span><span class=\"p\">}</span> </span><span id=\"__span-2-16\"><a id=\"__codelineno-2-16\" name=\"__codelineno-2-16\" href=\"#__codelineno-2-16\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"title\"</span><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"Title\"</span><span class=\"w\"> </span><span class=\"nx\">rules</span><span class=\"o\">=</span><span class=\"p\">{[{</span><span class=\"w\"> </span><span class=\"nx\">required</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">true</span><span class=\"w\"> </span><span class=\"p\">}]}</span><span class=\"o\">></span> </span><span id=\"__span-2-17\"><a id=\"__codelineno-2-17\" name=\"__codelineno-2-17\" href=\"#__codelineno-2-17\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Input</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-2-18\"><a id=\"__codelineno-2-18\" name=\"__codelineno-2-18\" href=\"#__codelineno-2-18\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-19\"><a id=\"__codelineno-2-19\" name=\"__codelineno-2-19\" href=\"#__codelineno-2-19\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"description\"</span><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"Description\"</span><span class=\"o\">></span> </span><span id=\"__span-2-20\"><a id=\"__codelineno-2-20\" name=\"__codelineno-2-20\" href=\"#__codelineno-2-20\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">TextArea</span><span class=\"w\"> </span><span class=\"nx\">rows</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"mf\">2</span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-2-21\"><a id=\"__codelineno-2-21\" name=\"__codelineno-2-21\" href=\"#__codelineno-2-21\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-22\"><a id=\"__codelineno-2-22\" name=\"__codelineno-2-22\" href=\"#__codelineno-2-22\"></a> </span><span id=\"__span-2-23\"><a id=\"__codelineno-2-23\" name=\"__codelineno-2-23\" href=\"#__codelineno-2-23\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"cm\">/* SEO Settings */</span><span class=\"p\">}</span> </span><span id=\"__span-2-24\"><a id=\"__codelineno-2-24\" name=\"__codelineno-2-24\" href=\"#__codelineno-2-24\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"seoTitle\"</span><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"SEO Title\"</span><span class=\"o\">></span> </span><span id=\"__span-2-25\"><a id=\"__codelineno-2-25\" name=\"__codelineno-2-25\" href=\"#__codelineno-2-25\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Input</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-2-26\"><a id=\"__codelineno-2-26\" name=\"__codelineno-2-26\" href=\"#__codelineno-2-26\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-27\"><a id=\"__codelineno-2-27\" name=\"__codelineno-2-27\" href=\"#__codelineno-2-27\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"seoDescription\"</span><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"SEO Description\"</span><span class=\"o\">></span> </span><span id=\"__span-2-28\"><a id=\"__codelineno-2-28\" name=\"__codelineno-2-28\" href=\"#__codelineno-2-28\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">TextArea</span><span class=\"w\"> </span><span class=\"nx\">rows</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"mf\">2</span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-2-29\"><a id=\"__codelineno-2-29\" name=\"__codelineno-2-29\" href=\"#__codelineno-2-29\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-30\"><a id=\"__codelineno-2-30\" name=\"__codelineno-2-30\" href=\"#__codelineno-2-30\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"seoImage\"</span><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"SEO Image URL\"</span><span class=\"o\">></span> </span><span id=\"__span-2-31\"><a id=\"__codelineno-2-31\" name=\"__codelineno-2-31\" href=\"#__codelineno-2-31\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Input</span><span class=\"w\"> </span><span class=\"nx\">placeholder</span><span class=\"o\">=</span><span class=\"s2\">\"https://...\"</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-2-32\"><a id=\"__codelineno-2-32\" name=\"__codelineno-2-32\" href=\"#__codelineno-2-32\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-33\"><a id=\"__codelineno-2-33\" name=\"__codelineno-2-33\" href=\"#__codelineno-2-33\"></a> </span><span id=\"__span-2-34\"><a id=\"__codelineno-2-34\" name=\"__codelineno-2-34\" href=\"#__codelineno-2-34\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Divider</span><span class=\"o\">></span><span class=\"nx\">MkDocs</span><span class=\"w\"> </span><span class=\"nx\">Integration</span><span class=\"o\"><</span><span class=\"err\">/Divider></span> </span><span id=\"__span-2-35\"><a id=\"__codelineno-2-35\" name=\"__codelineno-2-35\" href=\"#__codelineno-2-35\"></a> </span><span id=\"__span-2-36\"><a id=\"__codelineno-2-36\" name=\"__codelineno-2-36\" href=\"#__codelineno-2-36\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"cm\">/* MkDocs Settings */</span><span class=\"p\">}</span> </span><span id=\"__span-2-37\"><a id=\"__codelineno-2-37\" name=\"__codelineno-2-37\" href=\"#__codelineno-2-37\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span> </span><span id=\"__span-2-38\"><a id=\"__codelineno-2-38\" name=\"__codelineno-2-38\" href=\"#__codelineno-2-38\"></a><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"mkdocsSkipExport\"</span> </span><span id=\"__span-2-39\"><a id=\"__codelineno-2-39\" name=\"__codelineno-2-39\" href=\"#__codelineno-2-39\"></a><span class=\"w\"> </span><span class=\"nx\">valuePropName</span><span class=\"o\">=</span><span class=\"s2\">\"checked\"</span> </span><span id=\"__span-2-40\"><a id=\"__codelineno-2-40\" name=\"__codelineno-2-40\" href=\"#__codelineno-2-40\"></a><span class=\"w\"> </span><span class=\"nx\">help</span><span class=\"o\">=</span><span class=\"s2\">\"When enabled, this page will not be exported to MkDocs even when published.\"</span> </span><span id=\"__span-2-41\"><a id=\"__codelineno-2-41\" name=\"__codelineno-2-41\" href=\"#__codelineno-2-41\"></a><span class=\"w\"> </span><span class=\"o\">></span> </span><span id=\"__span-2-42\"><a id=\"__codelineno-2-42\" name=\"__codelineno-2-42\" href=\"#__codelineno-2-42\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Checkbox</span><span class=\"o\">></span><span class=\"nx\">Skip</span><span class=\"w\"> </span><span class=\"nx\">MkDocs</span><span class=\"w\"> </span><span class=\"nx\">Export</span><span class=\"o\"><</span><span class=\"err\">/Checkbox></span> </span><span id=\"__span-2-43\"><a id=\"__codelineno-2-43\" name=\"__codelineno-2-43\" href=\"#__codelineno-2-43\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-44\"><a id=\"__codelineno-2-44\" name=\"__codelineno-2-44\" href=\"#__codelineno-2-44\"></a> </span><span id=\"__span-2-45\"><a id=\"__codelineno-2-45\" name=\"__codelineno-2-45\" href=\"#__codelineno-2-45\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"cm\">/* Conditional fields (only shown if not skipping export) */</span><span class=\"p\">}</span> </span><span id=\"__span-2-46\"><a id=\"__codelineno-2-46\" name=\"__codelineno-2-46\" href=\"#__codelineno-2-46\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">noStyle</span><span class=\"w\"> </span><span class=\"nx\">shouldUpdate</span><span class=\"o\">=</span><span class=\"p\">{(</span><span class=\"nx\">prev</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">cur</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">prev</span><span class=\"p\">.</span><span class=\"nx\">mkdocsSkipExport</span><span class=\"w\"> </span><span class=\"o\">!==</span><span class=\"w\"> </span><span class=\"nx\">cur</span><span class=\"p\">.</span><span class=\"nx\">mkdocsSkipExport</span><span class=\"p\">}</span><span class=\"o\">></span> </span><span id=\"__span-2-47\"><a id=\"__codelineno-2-47\" name=\"__codelineno-2-47\" href=\"#__codelineno-2-47\"></a><span class=\"w\"> </span><span class=\"p\">{({</span><span class=\"w\"> </span><span class=\"nx\">getFieldValue</span><span class=\"w\"> </span><span class=\"p\">})</span><span class=\"w\"> </span><span class=\"p\">=></span> </span><span id=\"__span-2-48\"><a id=\"__codelineno-2-48\" name=\"__codelineno-2-48\" href=\"#__codelineno-2-48\"></a><span class=\"w\"> </span><span class=\"o\">!</span><span class=\"nx\">getFieldValue</span><span class=\"p\">(</span><span class=\"s1\">'mkdocsSkipExport'</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"o\">&&</span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-2-49\"><a id=\"__codelineno-2-49\" name=\"__codelineno-2-49\" href=\"#__codelineno-2-49\"></a><span class=\"w\"> </span><span class=\"o\"><></span> </span><span id=\"__span-2-50\"><a id=\"__codelineno-2-50\" name=\"__codelineno-2-50\" href=\"#__codelineno-2-50\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"mkdocsPath\"</span><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"Override Path\"</span><span class=\"o\">></span> </span><span id=\"__span-2-51\"><a id=\"__codelineno-2-51\" name=\"__codelineno-2-51\" href=\"#__codelineno-2-51\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Input</span><span class=\"w\"> </span><span class=\"nx\">placeholder</span><span class=\"o\">=</span><span class=\"s2\">\"e.g. about.html\"</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-2-52\"><a id=\"__codelineno-2-52\" name=\"__codelineno-2-52\" href=\"#__codelineno-2-52\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-53\"><a id=\"__codelineno-2-53\" name=\"__codelineno-2-53\" href=\"#__codelineno-2-53\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span> </span><span id=\"__span-2-54\"><a id=\"__codelineno-2-54\" name=\"__codelineno-2-54\" href=\"#__codelineno-2-54\"></a><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"mkdocsExportMode\"</span> </span><span id=\"__span-2-55\"><a id=\"__codelineno-2-55\" name=\"__codelineno-2-55\" href=\"#__codelineno-2-55\"></a><span class=\"w\"> </span><span class=\"nx\">valuePropName</span><span class=\"o\">=</span><span class=\"s2\">\"checked\"</span> </span><span id=\"__span-2-56\"><a id=\"__codelineno-2-56\" name=\"__codelineno-2-56\" href=\"#__codelineno-2-56\"></a><span class=\"w\"> </span><span class=\"nx\">getValueFromEvent</span><span class=\"o\">=</span><span class=\"p\">{(</span><span class=\"nx\">e</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">e</span><span class=\"p\">.</span><span class=\"nx\">target</span><span class=\"p\">.</span><span class=\"nx\">checked</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'STANDALONE'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'THEMED'</span><span class=\"p\">}</span> </span><span id=\"__span-2-57\"><a id=\"__codelineno-2-57\" name=\"__codelineno-2-57\" href=\"#__codelineno-2-57\"></a><span class=\"w\"> </span><span class=\"nx\">getValueProps</span><span class=\"o\">=</span><span class=\"p\">{(</span><span class=\"nx\">value</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">({</span><span class=\"w\"> </span><span class=\"nx\">checked</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">value</span><span class=\"w\"> </span><span class=\"o\">===</span><span class=\"w\"> </span><span class=\"s1\">'STANDALONE'</span><span class=\"w\"> </span><span class=\"p\">})}</span> </span><span id=\"__span-2-58\"><a id=\"__codelineno-2-58\" name=\"__codelineno-2-58\" href=\"#__codelineno-2-58\"></a><span class=\"w\"> </span><span class=\"nx\">help</span><span class=\"o\">=</span><span class=\"s2\">\"Publish as a full HTML page with no MkDocs header, footer, or theme\"</span> </span><span id=\"__span-2-59\"><a id=\"__codelineno-2-59\" name=\"__codelineno-2-59\" href=\"#__codelineno-2-59\"></a><span class=\"w\"> </span><span class=\"o\">></span> </span><span id=\"__span-2-60\"><a id=\"__codelineno-2-60\" name=\"__codelineno-2-60\" href=\"#__codelineno-2-60\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Checkbox</span><span class=\"o\">></span><span class=\"nx\">Full</span><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"w\"> </span><span class=\"nx\">MkDocs</span><span class=\"o\"><</span><span class=\"err\">/Checkbox></span> </span><span id=\"__span-2-61\"><a id=\"__codelineno-2-61\" name=\"__codelineno-2-61\" href=\"#__codelineno-2-61\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-62\"><a id=\"__codelineno-2-62\" name=\"__codelineno-2-62\" href=\"#__codelineno-2-62\"></a> </span><span id=\"__span-2-63\"><a id=\"__codelineno-2-63\" name=\"__codelineno-2-63\" href=\"#__codelineno-2-63\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"cm\">/* Conditional theme fields (only shown if not standalone) */</span><span class=\"p\">}</span> </span><span id=\"__span-2-64\"><a id=\"__codelineno-2-64\" name=\"__codelineno-2-64\" href=\"#__codelineno-2-64\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">noStyle</span><span class=\"w\"> </span><span class=\"nx\">shouldUpdate</span><span class=\"o\">=</span><span class=\"p\">{(</span><span class=\"nx\">prev</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">cur</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">prev</span><span class=\"p\">.</span><span class=\"nx\">mkdocsExportMode</span><span class=\"w\"> </span><span class=\"o\">!==</span><span class=\"w\"> </span><span class=\"nx\">cur</span><span class=\"p\">.</span><span class=\"nx\">mkdocsExportMode</span><span class=\"p\">}</span><span class=\"o\">></span> </span><span id=\"__span-2-65\"><a id=\"__codelineno-2-65\" name=\"__codelineno-2-65\" href=\"#__codelineno-2-65\"></a><span class=\"w\"> </span><span class=\"p\">{({</span><span class=\"w\"> </span><span class=\"nx\">getFieldValue</span><span class=\"w\"> </span><span class=\"p\">})</span><span class=\"w\"> </span><span class=\"p\">=></span> </span><span id=\"__span-2-66\"><a id=\"__codelineno-2-66\" name=\"__codelineno-2-66\" href=\"#__codelineno-2-66\"></a><span class=\"w\"> </span><span class=\"nx\">getFieldValue</span><span class=\"p\">(</span><span class=\"s1\">'mkdocsExportMode'</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"o\">!==</span><span class=\"w\"> </span><span class=\"s1\">'STANDALONE'</span><span class=\"w\"> </span><span class=\"o\">&&</span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-2-67\"><a id=\"__codelineno-2-67\" name=\"__codelineno-2-67\" href=\"#__codelineno-2-67\"></a><span class=\"w\"> </span><span class=\"o\"><></span> </span><span id=\"__span-2-68\"><a id=\"__codelineno-2-68\" name=\"__codelineno-2-68\" href=\"#__codelineno-2-68\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"mkdocsHideNav\"</span><span class=\"w\"> </span><span class=\"nx\">valuePropName</span><span class=\"o\">=</span><span class=\"s2\">\"checked\"</span><span class=\"o\">></span> </span><span id=\"__span-2-69\"><a id=\"__codelineno-2-69\" name=\"__codelineno-2-69\" href=\"#__codelineno-2-69\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Checkbox</span><span class=\"o\">></span><span class=\"nx\">Hide</span><span class=\"w\"> </span><span class=\"nx\">navigation</span><span class=\"w\"> </span><span class=\"nx\">sidebar</span><span class=\"o\"><</span><span class=\"err\">/Checkbox></span> </span><span id=\"__span-2-70\"><a id=\"__codelineno-2-70\" name=\"__codelineno-2-70\" href=\"#__codelineno-2-70\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-71\"><a id=\"__codelineno-2-71\" name=\"__codelineno-2-71\" href=\"#__codelineno-2-71\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"mkdocsHideToc\"</span><span class=\"w\"> </span><span class=\"nx\">valuePropName</span><span class=\"o\">=</span><span class=\"s2\">\"checked\"</span><span class=\"o\">></span> </span><span id=\"__span-2-72\"><a id=\"__codelineno-2-72\" name=\"__codelineno-2-72\" href=\"#__codelineno-2-72\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Checkbox</span><span class=\"o\">></span><span class=\"nx\">Hide</span><span class=\"w\"> </span><span class=\"nx\">table</span><span class=\"w\"> </span><span class=\"k\">of</span><span class=\"w\"> </span><span class=\"nx\">contents</span><span class=\"o\"><</span><span class=\"err\">/Checkbox></span> </span><span id=\"__span-2-73\"><a id=\"__codelineno-2-73\" name=\"__codelineno-2-73\" href=\"#__codelineno-2-73\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-74\"><a id=\"__codelineno-2-74\" name=\"__codelineno-2-74\" href=\"#__codelineno-2-74\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/></span> </span><span id=\"__span-2-75\"><a id=\"__codelineno-2-75\" name=\"__codelineno-2-75\" href=\"#__codelineno-2-75\"></a><span class=\"w\"> </span><span class=\"p\">)</span> </span><span id=\"__span-2-76\"><a id=\"__codelineno-2-76\" name=\"__codelineno-2-76\" href=\"#__codelineno-2-76\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-2-77\"><a id=\"__codelineno-2-77\" name=\"__codelineno-2-77\" href=\"#__codelineno-2-77\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-78\"><a id=\"__codelineno-2-78\" name=\"__codelineno-2-78\" href=\"#__codelineno-2-78\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/></span> </span><span id=\"__span-2-79\"><a id=\"__codelineno-2-79\" name=\"__codelineno-2-79\" href=\"#__codelineno-2-79\"></a><span class=\"w\"> </span><span class=\"p\">)</span> </span><span id=\"__span-2-80\"><a id=\"__codelineno-2-80\" name=\"__codelineno-2-80\" href=\"#__codelineno-2-80\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-2-81\"><a id=\"__codelineno-2-81\" name=\"__codelineno-2-81\" href=\"#__codelineno-2-81\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-82\"><a id=\"__codelineno-2-82\" name=\"__codelineno-2-82\" href=\"#__codelineno-2-82\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form></span> </span><span id=\"__span-2-83\"><a id=\"__codelineno-2-83\" name=\"__codelineno-2-83\" href=\"#__codelineno-2-83\"></a><span class=\"o\"><</span><span class=\"err\">/Modal></span> </span></code></pre></div> <p><strong>Settings Modal Features:</strong> - <strong>Wider modal:</strong> 560px width (accommodates longer labels + descriptions) - <strong>Conditional fields:</strong> MkDocs fields hidden if \"Skip Export\" checked - <strong>Nested conditional fields:</strong> Theme fields hidden if \"Full page MkDocs\" checked - <strong>Help text:</strong> Explains what each setting does - <strong>Value transformations:</strong> <code>mkdocsExportMode</code> stored as 'STANDALONE'/'THEMED', displayed as checkbox</p> <h2 id=\"state-management\">State Management<a class=\"headerlink\" href=\"#state-management\" title=\"Permanent link\">\u00b6</a></h2> <h3 id=\"local-state-no-zustand-store\">Local State (No Zustand Store)<a class=\"headerlink\" href=\"#local-state-no-zustand-store\" title=\"Permanent link\">\u00b6</a></h3> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-3-1\"><a id=\"__codelineno-3-1\" name=\"__codelineno-3-1\" href=\"#__codelineno-3-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">pages</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setPages</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"o\"><</span><span class=\"nx\">LandingPage</span><span class=\"p\">[]</span><span class=\"o\">></span><span class=\"p\">([]);</span> </span><span id=\"__span-3-2\"><a id=\"__codelineno-3-2\" name=\"__codelineno-3-2\" href=\"#__codelineno-3-2\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">pagination</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setPagination</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"p\">({</span><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">1</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">limit</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">20</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">total</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">0</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">totalPages</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">0</span><span class=\"w\"> </span><span class=\"p\">});</span> </span><span id=\"__span-3-3\"><a id=\"__codelineno-3-3\" name=\"__codelineno-3-3\" href=\"#__codelineno-3-3\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">loading</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setLoading</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-3-4\"><a id=\"__codelineno-3-4\" name=\"__codelineno-3-4\" href=\"#__codelineno-3-4\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">syncing</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setSyncing</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-3-5\"><a id=\"__codelineno-3-5\" name=\"__codelineno-3-5\" href=\"#__codelineno-3-5\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">validating</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setValidating</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-3-6\"><a id=\"__codelineno-3-6\" name=\"__codelineno-3-6\" href=\"#__codelineno-3-6\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">search</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setSearch</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"p\">(</span><span class=\"s1\">''</span><span class=\"p\">);</span> </span><span id=\"__span-3-7\"><a id=\"__codelineno-3-7\" name=\"__codelineno-3-7\" href=\"#__codelineno-3-7\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">debouncedSearch</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setDebouncedSearch</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"p\">(</span><span class=\"s1\">''</span><span class=\"p\">);</span> </span><span id=\"__span-3-8\"><a id=\"__codelineno-3-8\" name=\"__codelineno-3-8\" href=\"#__codelineno-3-8\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">searchTimerRef</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useRef</span><span class=\"o\"><</span><span class=\"nx\">ReturnType</span><span class=\"o\"><</span><span class=\"ow\">typeof</span><span class=\"w\"> </span><span class=\"nx\">setTimeout</span><span class=\"o\">>></span><span class=\"p\">(</span><span class=\"kc\">undefined</span><span class=\"p\">);</span> </span><span id=\"__span-3-9\"><a id=\"__codelineno-3-9\" name=\"__codelineno-3-9\" href=\"#__codelineno-3-9\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">publishedFilter</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setPublishedFilter</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"o\"><</span><span class=\"s1\">'true'</span><span class=\"w\"> </span><span class=\"o\">|</span><span class=\"w\"> </span><span class=\"s1\">'false'</span><span class=\"w\"> </span><span class=\"o\">|</span><span class=\"w\"> </span><span class=\"kc\">undefined</span><span class=\"o\">></span><span class=\"p\">();</span> </span><span id=\"__span-3-10\"><a id=\"__codelineno-3-10\" name=\"__codelineno-3-10\" href=\"#__codelineno-3-10\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">createModalOpen</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setCreateModalOpen</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-3-11\"><a id=\"__codelineno-3-11\" name=\"__codelineno-3-11\" href=\"#__codelineno-3-11\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">settingsModalOpen</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setSettingsModalOpen</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-3-12\"><a id=\"__codelineno-3-12\" name=\"__codelineno-3-12\" href=\"#__codelineno-3-12\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">editingPage</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setEditingPage</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"o\"><</span><span class=\"nx\">LandingPage</span><span class=\"w\"> </span><span class=\"o\">|</span><span class=\"w\"> </span><span class=\"kc\">null</span><span class=\"o\">></span><span class=\"p\">(</span><span class=\"kc\">null</span><span class=\"p\">);</span> </span><span id=\"__span-3-13\"><a id=\"__codelineno-3-13\" name=\"__codelineno-3-13\" href=\"#__codelineno-3-13\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">createForm</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">useForm</span><span class=\"p\">();</span> </span><span id=\"__span-3-14\"><a id=\"__codelineno-3-14\" name=\"__codelineno-3-14\" href=\"#__codelineno-3-14\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">settingsForm</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">useForm</span><span class=\"p\">();</span> </span></code></pre></div> <p><strong>State Variables:</strong> - <code>pages</code> (array): Current page of landing pages - <code>pagination</code> (object): Pagination state (page, limit, total, totalPages) - <code>loading</code> (boolean): Table loading state - <code>syncing</code> (boolean): Sync Overrides button loading state - <code>validating</code> (boolean): Validate Exports button loading state - <code>search</code> (string): Immediate search input value - <code>debouncedSearch</code> (string): Debounced search value (triggers API call) - <code>searchTimerRef</code> (ref): Debounce timer reference - <code>publishedFilter</code> (string | undefined): Status filter ('true', 'false', or undefined for all) - <code>createModalOpen</code> (boolean): Create modal visibility - <code>settingsModalOpen</code> (boolean): Settings modal visibility - <code>editingPage</code> (LandingPage | null): Page being edited in settings modal - <code>createForm</code> (Form): Ant Design form instance for create modal - <code>settingsForm</code> (Form): Ant Design form instance for settings modal</p> <p><strong>No Global State:</strong></p> <p>This page does NOT use Zustand stores. Landing page data is fetched directly from the API and stored in local state. This is appropriate because: - Landing pages are admin-only data - Data changes infrequently (manual edits) - No need to share state between pages (public renderer fetches page independently) - Simpler architecture without store overhead</p> <h3 id=\"debounced-search-pattern\">Debounced Search Pattern<a class=\"headerlink\" href=\"#debounced-search-pattern\" title=\"Permanent link\">\u00b6</a></h3> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-4-1\"><a id=\"__codelineno-4-1\" name=\"__codelineno-4-1\" href=\"#__codelineno-4-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">handleSearchChange</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">value</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-4-2\"><a id=\"__codelineno-4-2\" name=\"__codelineno-4-2\" href=\"#__codelineno-4-2\"></a><span class=\"w\"> </span><span class=\"nx\">setSearch</span><span class=\"p\">(</span><span class=\"nx\">value</span><span class=\"p\">);</span><span class=\"w\"> </span><span class=\"c1\">// Update immediate value (for input controlled state)</span> </span><span id=\"__span-4-3\"><a id=\"__codelineno-4-3\" name=\"__codelineno-4-3\" href=\"#__codelineno-4-3\"></a><span class=\"w\"> </span><span class=\"nx\">clearTimeout</span><span class=\"p\">(</span><span class=\"nx\">searchTimerRef</span><span class=\"p\">.</span><span class=\"nx\">current</span><span class=\"p\">);</span> </span><span id=\"__span-4-4\"><a id=\"__codelineno-4-4\" name=\"__codelineno-4-4\" href=\"#__codelineno-4-4\"></a><span class=\"w\"> </span><span class=\"nx\">searchTimerRef</span><span class=\"p\">.</span><span class=\"nx\">current</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">setTimeout</span><span class=\"p\">(()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">setDebouncedSearch</span><span class=\"p\">(</span><span class=\"nx\">value</span><span class=\"p\">),</span><span class=\"w\"> </span><span class=\"mf\">300</span><span class=\"p\">);</span> </span><span id=\"__span-4-5\"><a id=\"__codelineno-4-5\" name=\"__codelineno-4-5\" href=\"#__codelineno-4-5\"></a><span class=\"p\">};</span> </span><span id=\"__span-4-6\"><a id=\"__codelineno-4-6\" name=\"__codelineno-4-6\" href=\"#__codelineno-4-6\"></a> </span><span id=\"__span-4-7\"><a id=\"__codelineno-4-7\" name=\"__codelineno-4-7\" href=\"#__codelineno-4-7\"></a><span class=\"nx\">useEffect</span><span class=\"p\">(()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-4-8\"><a id=\"__codelineno-4-8\" name=\"__codelineno-4-8\" href=\"#__codelineno-4-8\"></a><span class=\"w\"> </span><span class=\"k\">return</span><span class=\"w\"> </span><span class=\"p\">()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">clearTimeout</span><span class=\"p\">(</span><span class=\"nx\">searchTimerRef</span><span class=\"p\">.</span><span class=\"nx\">current</span><span class=\"p\">);</span><span class=\"w\"> </span><span class=\"c1\">// Cleanup on unmount</span> </span><span id=\"__span-4-9\"><a id=\"__codelineno-4-9\" name=\"__codelineno-4-9\" href=\"#__codelineno-4-9\"></a><span class=\"p\">},</span><span class=\"w\"> </span><span class=\"p\">[]);</span> </span><span id=\"__span-4-10\"><a id=\"__codelineno-4-10\" name=\"__codelineno-4-10\" href=\"#__codelineno-4-10\"></a> </span><span id=\"__span-4-11\"><a id=\"__codelineno-4-11\" name=\"__codelineno-4-11\" href=\"#__codelineno-4-11\"></a><span class=\"nx\">useEffect</span><span class=\"p\">(()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-4-12\"><a id=\"__codelineno-4-12\" name=\"__codelineno-4-12\" href=\"#__codelineno-4-12\"></a><span class=\"w\"> </span><span class=\"nx\">fetchPages</span><span class=\"p\">({</span><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">1</span><span class=\"w\"> </span><span class=\"p\">});</span> </span><span id=\"__span-4-13\"><a id=\"__codelineno-4-13\" name=\"__codelineno-4-13\" href=\"#__codelineno-4-13\"></a><span class=\"p\">},</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">debouncedSearch</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">publishedFilter</span><span class=\"p\">]);</span><span class=\"w\"> </span><span class=\"c1\">// Trigger fetch when debounced search or filter changes</span> </span></code></pre></div> <p><strong>Why Two Search States?</strong></p> <ul> <li><strong>Immediate (<code>search</code>):</strong> Input value updates instantly (responsive input)</li> <li><strong>Debounced (<code>debouncedSearch</code>):</strong> API call triggers after 300ms pause</li> <li><strong>Result:</strong> Smooth typing experience without API spam</li> </ul> <h3 id=\"conditional-settings-form-fields\">Conditional Settings Form Fields<a class=\"headerlink\" href=\"#conditional-settings-form-fields\" title=\"Permanent link\">\u00b6</a></h3> <p>Settings form uses <code>shouldUpdate</code> to conditionally show/hide fields:</p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-5-1\"><a id=\"__codelineno-5-1\" name=\"__codelineno-5-1\" href=\"#__codelineno-5-1\"></a><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">noStyle</span><span class=\"w\"> </span><span class=\"nx\">shouldUpdate</span><span class=\"o\">=</span><span class=\"p\">{(</span><span class=\"nx\">prev</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">cur</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">prev</span><span class=\"p\">.</span><span class=\"nx\">mkdocsSkipExport</span><span class=\"w\"> </span><span class=\"o\">!==</span><span class=\"w\"> </span><span class=\"nx\">cur</span><span class=\"p\">.</span><span class=\"nx\">mkdocsSkipExport</span><span class=\"p\">}</span><span class=\"o\">></span> </span><span id=\"__span-5-2\"><a id=\"__codelineno-5-2\" name=\"__codelineno-5-2\" href=\"#__codelineno-5-2\"></a><span class=\"w\"> </span><span class=\"p\">{({</span><span class=\"w\"> </span><span class=\"nx\">getFieldValue</span><span class=\"w\"> </span><span class=\"p\">})</span><span class=\"w\"> </span><span class=\"p\">=></span> </span><span id=\"__span-5-3\"><a id=\"__codelineno-5-3\" name=\"__codelineno-5-3\" href=\"#__codelineno-5-3\"></a><span class=\"w\"> </span><span class=\"o\">!</span><span class=\"nx\">getFieldValue</span><span class=\"p\">(</span><span class=\"s1\">'mkdocsSkipExport'</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"o\">&&</span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-5-4\"><a id=\"__codelineno-5-4\" name=\"__codelineno-5-4\" href=\"#__codelineno-5-4\"></a><span class=\"w\"> </span><span class=\"o\"><></span> </span><span id=\"__span-5-5\"><a id=\"__codelineno-5-5\" name=\"__codelineno-5-5\" href=\"#__codelineno-5-5\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"mkdocsPath\"</span><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"Override Path\"</span><span class=\"o\">></span> </span><span id=\"__span-5-6\"><a id=\"__codelineno-5-6\" name=\"__codelineno-5-6\" href=\"#__codelineno-5-6\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Input</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-5-7\"><a id=\"__codelineno-5-7\" name=\"__codelineno-5-7\" href=\"#__codelineno-5-7\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-5-8\"><a id=\"__codelineno-5-8\" name=\"__codelineno-5-8\" href=\"#__codelineno-5-8\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"cm\">/* Other MkDocs fields */</span><span class=\"p\">}</span> </span><span id=\"__span-5-9\"><a id=\"__codelineno-5-9\" name=\"__codelineno-5-9\" href=\"#__codelineno-5-9\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/></span> </span><span id=\"__span-5-10\"><a id=\"__codelineno-5-10\" name=\"__codelineno-5-10\" href=\"#__codelineno-5-10\"></a><span class=\"w\"> </span><span class=\"p\">)</span> </span><span id=\"__span-5-11\"><a id=\"__codelineno-5-11\" name=\"__codelineno-5-11\" href=\"#__codelineno-5-11\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-5-12\"><a id=\"__codelineno-5-12\" name=\"__codelineno-5-12\" href=\"#__codelineno-5-12\"></a><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span></code></pre></div> <p><strong>How It Works:</strong> - <code>shouldUpdate</code>: Function returns true when <code>mkdocsSkipExport</code> changes - <code>getFieldValue</code>: Reads current value of <code>mkdocsSkipExport</code> checkbox - Conditional rendering: MkDocs fields only rendered if checkbox NOT checked - <strong>Result:</strong> Form dynamically shows/hides fields based on checkbox state</p> <h2 id=\"api-integration\">API Integration<a class=\"headerlink\" href=\"#api-integration\" title=\"Permanent link\">\u00b6</a></h2> <h3 id=\"endpoints-used\">Endpoints Used<a class=\"headerlink\" href=\"#endpoints-used\" title=\"Permanent link\">\u00b6</a></h3> <table> <thead> <tr> <th>Method</th> <th>Endpoint</th> <th>Purpose</th> <th>Auth</th> </tr> </thead> <tbody> <tr> <td>GET</td> <td><code>/api/pages</code></td> <td>List pages (paginated, filtered)</td> <td>Required</td> </tr> <tr> <td>POST</td> <td><code>/api/pages</code></td> <td>Create new page</td> <td>Required</td> </tr> <tr> <td>PUT</td> <td><code>/api/pages/:id</code></td> <td>Update page metadata/settings</td> <td>Required</td> </tr> <tr> <td>DELETE</td> <td><code>/api/pages/:id</code></td> <td>Delete page</td> <td>Required</td> </tr> <tr> <td>POST</td> <td><code>/api/pages/sync</code></td> <td>Sync MkDocs overrides</td> <td>Required</td> </tr> <tr> <td>POST</td> <td><code>/api/pages/validate</code></td> <td>Validate MkDocs exports</td> <td>Required</td> </tr> <tr> <td>POST</td> <td><code>/api/docs/build</code></td> <td>Build MkDocs site</td> <td>Required (SUPER_ADMIN)</td> </tr> </tbody> </table> <h3 id=\"load-landing-pages-paginated-with-filters\">Load Landing Pages (Paginated with Filters)<a class=\"headerlink\" href=\"#load-landing-pages-paginated-with-filters\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Request:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-6-1\"><a id=\"__codelineno-6-1\" name=\"__codelineno-6-1\" href=\"#__codelineno-6-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">get</span><span class=\"o\"><</span><span class=\"nx\">LandingPagesListResponse</span><span class=\"o\">></span><span class=\"p\">(</span><span class=\"s1\">'/pages'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-6-2\"><a id=\"__codelineno-6-2\" name=\"__codelineno-6-2\" href=\"#__codelineno-6-2\"></a><span class=\"w\"> </span><span class=\"nx\">params</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-6-3\"><a id=\"__codelineno-6-3\" name=\"__codelineno-6-3\" href=\"#__codelineno-6-3\"></a><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">1</span><span class=\"p\">,</span> </span><span id=\"__span-6-4\"><a id=\"__codelineno-6-4\" name=\"__codelineno-6-4\" href=\"#__codelineno-6-4\"></a><span class=\"w\"> </span><span class=\"nx\">limit</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">20</span><span class=\"p\">,</span> </span><span id=\"__span-6-5\"><a id=\"__codelineno-6-5\" name=\"__codelineno-6-5\" href=\"#__codelineno-6-5\"></a><span class=\"w\"> </span><span class=\"nx\">search</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'campaign'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"c1\">// Optional: search query</span> </span><span id=\"__span-6-6\"><a id=\"__codelineno-6-6\" name=\"__codelineno-6-6\" href=\"#__codelineno-6-6\"></a><span class=\"w\"> </span><span class=\"nx\">published</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'true'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"c1\">// Optional: filter by published status</span> </span><span id=\"__span-6-7\"><a id=\"__codelineno-6-7\" name=\"__codelineno-6-7\" href=\"#__codelineno-6-7\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-6-8\"><a id=\"__codelineno-6-8\" name=\"__codelineno-6-8\" href=\"#__codelineno-6-8\"></a><span class=\"p\">});</span> </span></code></pre></div> <p><strong>Query Parameters:</strong> - <code>page</code> (number, required): Page number (1-indexed) - <code>limit</code> (number, required): Items per page (20, 50, or 100) - <code>search</code> (string, optional): Search query (matches title, description) - <code>published</code> (string, optional): Filter by status ('true', 'false', or omit for all)</p> <p><strong>Response (200 OK):</strong></p> <div class=\"language-json highlight\"><pre><span></span><code><span id=\"__span-7-1\"><a id=\"__codelineno-7-1\" name=\"__codelineno-7-1\" href=\"#__codelineno-7-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-7-2\"><a id=\"__codelineno-7-2\" name=\"__codelineno-7-2\" href=\"#__codelineno-7-2\"></a><span class=\"w\"> </span><span class=\"nt\">\"pages\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"p\">[</span> </span><span id=\"__span-7-3\"><a id=\"__codelineno-7-3\" name=\"__codelineno-7-3\" href=\"#__codelineno-7-3\"></a><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-7-4\"><a id=\"__codelineno-7-4\" name=\"__codelineno-7-4\" href=\"#__codelineno-7-4\"></a><span class=\"w\"> </span><span class=\"nt\">\"id\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"page_abc123\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-5\"><a id=\"__codelineno-7-5\" name=\"__codelineno-7-5\" href=\"#__codelineno-7-5\"></a><span class=\"w\"> </span><span class=\"nt\">\"title\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"About Our Campaign\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-6\"><a id=\"__codelineno-7-6\" name=\"__codelineno-7-6\" href=\"#__codelineno-7-6\"></a><span class=\"w\"> </span><span class=\"nt\">\"description\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"Learn about our mission and values\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-7\"><a id=\"__codelineno-7-7\" name=\"__codelineno-7-7\" href=\"#__codelineno-7-7\"></a><span class=\"w\"> </span><span class=\"nt\">\"slug\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"about-our-campaign\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-8\"><a id=\"__codelineno-7-8\" name=\"__codelineno-7-8\" href=\"#__codelineno-7-8\"></a><span class=\"w\"> </span><span class=\"nt\">\"editorMode\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"VISUAL\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-9\"><a id=\"__codelineno-7-9\" name=\"__codelineno-7-9\" href=\"#__codelineno-7-9\"></a><span class=\"w\"> </span><span class=\"nt\">\"published\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">true</span><span class=\"p\">,</span> </span><span id=\"__span-7-10\"><a id=\"__codelineno-7-10\" name=\"__codelineno-7-10\" href=\"#__codelineno-7-10\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsPath\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"about.html\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-11\"><a id=\"__codelineno-7-11\" name=\"__codelineno-7-11\" href=\"#__codelineno-7-11\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsStubPath\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"about.md\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-12\"><a id=\"__codelineno-7-12\" name=\"__codelineno-7-12\" href=\"#__codelineno-7-12\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsExportMode\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"THEMED\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-13\"><a id=\"__codelineno-7-13\" name=\"__codelineno-7-13\" href=\"#__codelineno-7-13\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsHideNav\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">false</span><span class=\"p\">,</span> </span><span id=\"__span-7-14\"><a id=\"__codelineno-7-14\" name=\"__codelineno-7-14\" href=\"#__codelineno-7-14\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsHideToc\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">false</span><span class=\"p\">,</span> </span><span id=\"__span-7-15\"><a id=\"__codelineno-7-15\" name=\"__codelineno-7-15\" href=\"#__codelineno-7-15\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsSkipExport\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">false</span><span class=\"p\">,</span> </span><span id=\"__span-7-16\"><a id=\"__codelineno-7-16\" name=\"__codelineno-7-16\" href=\"#__codelineno-7-16\"></a><span class=\"w\"> </span><span class=\"nt\">\"seoTitle\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"About Our Campaign | Campaign Name\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-17\"><a id=\"__codelineno-7-17\" name=\"__codelineno-7-17\" href=\"#__codelineno-7-17\"></a><span class=\"w\"> </span><span class=\"nt\">\"seoDescription\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"Learn about our mission, values, and team\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-18\"><a id=\"__codelineno-7-18\" name=\"__codelineno-7-18\" href=\"#__codelineno-7-18\"></a><span class=\"w\"> </span><span class=\"nt\">\"seoImage\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"https://example.com/og-image.png\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-19\"><a id=\"__codelineno-7-19\" name=\"__codelineno-7-19\" href=\"#__codelineno-7-19\"></a><span class=\"w\"> </span><span class=\"nt\">\"createdAt\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"2026-01-15T10:30:00.000Z\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-20\"><a id=\"__codelineno-7-20\" name=\"__codelineno-7-20\" href=\"#__codelineno-7-20\"></a><span class=\"w\"> </span><span class=\"nt\">\"updatedAt\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"2026-02-10T14:25:00.000Z\"</span> </span><span id=\"__span-7-21\"><a id=\"__codelineno-7-21\" name=\"__codelineno-7-21\" href=\"#__codelineno-7-21\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-7-22\"><a id=\"__codelineno-7-22\" name=\"__codelineno-7-22\" href=\"#__codelineno-7-22\"></a><span class=\"w\"> </span><span class=\"p\">],</span> </span><span id=\"__span-7-23\"><a id=\"__codelineno-7-23\" name=\"__codelineno-7-23\" href=\"#__codelineno-7-23\"></a><span class=\"w\"> </span><span class=\"nt\">\"pagination\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-7-24\"><a id=\"__codelineno-7-24\" name=\"__codelineno-7-24\" href=\"#__codelineno-7-24\"></a><span class=\"w\"> </span><span class=\"nt\">\"page\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">1</span><span class=\"p\">,</span> </span><span id=\"__span-7-25\"><a id=\"__codelineno-7-25\" name=\"__codelineno-7-25\" href=\"#__codelineno-7-25\"></a><span class=\"w\"> </span><span class=\"nt\">\"limit\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">20</span><span class=\"p\">,</span> </span><span id=\"__span-7-26\"><a id=\"__codelineno-7-26\" name=\"__codelineno-7-26\" href=\"#__codelineno-7-26\"></a><span class=\"w\"> </span><span class=\"nt\">\"total\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">24</span><span class=\"p\">,</span> </span><span id=\"__span-7-27\"><a id=\"__codelineno-7-27\" name=\"__codelineno-7-27\" href=\"#__codelineno-7-27\"></a><span class=\"w\"> </span><span class=\"nt\">\"totalPages\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">2</span> </span><span id=\"__span-7-28\"><a id=\"__codelineno-7-28\" name=\"__codelineno-7-28\" href=\"#__codelineno-7-28\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-7-29\"><a id=\"__codelineno-7-29\" name=\"__codelineno-7-29\" href=\"#__codelineno-7-29\"></a><span class=\"p\">}</span> </span></code></pre></div> <p><strong>Response Fields:</strong> - <code>id</code> (string): Unique page identifier (prefixed with \"page_\") - <code>title</code> (string): Page title - <code>description</code> (string | null): Optional page description - <code>slug</code> (string): URL-safe slug (used in <code>/p/:slug</code>) - <code>editorMode</code> (string): Editor type ('VISUAL' or 'CODE') - <code>published</code> (boolean): Publication status - <code>mkdocsPath</code> (string | null): Custom MkDocs path (e.g., \"about.html\") - <code>mkdocsStubPath</code> (string | null): Markdown stub path (e.g., \"about.md\") - <code>mkdocsExportMode</code> (string): Export mode ('THEMED' or 'STANDALONE') - <code>mkdocsHideNav</code> (boolean): Hide navigation sidebar in themed mode - <code>mkdocsHideToc</code> (boolean): Hide table of contents in themed mode - <code>mkdocsSkipExport</code> (boolean): Skip MkDocs export (keep /p/:slug only) - <code>seoTitle</code> (string | null): Custom SEO title (overrides page title) - <code>seoDescription</code> (string | null): Meta description for SEO - <code>seoImage</code> (string | null): Open Graph image URL - <code>createdAt</code> (ISO 8601): Creation timestamp - <code>updatedAt</code> (ISO 8601): Last update timestamp</p> <h3 id=\"create-landing-page\">Create Landing Page<a class=\"headerlink\" href=\"#create-landing-page\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Request:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-8-1\"><a id=\"__codelineno-8-1\" name=\"__codelineno-8-1\" href=\"#__codelineno-8-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">post</span><span class=\"o\"><</span><span class=\"nx\">LandingPage</span><span class=\"o\">></span><span class=\"p\">(</span><span class=\"s1\">'/pages'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-8-2\"><a id=\"__codelineno-8-2\" name=\"__codelineno-8-2\" href=\"#__codelineno-8-2\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'About Our Campaign'</span><span class=\"p\">,</span> </span><span id=\"__span-8-3\"><a id=\"__codelineno-8-3\" name=\"__codelineno-8-3\" href=\"#__codelineno-8-3\"></a><span class=\"w\"> </span><span class=\"nx\">description</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Learn about our mission and values'</span><span class=\"p\">,</span> </span><span id=\"__span-8-4\"><a id=\"__codelineno-8-4\" name=\"__codelineno-8-4\" href=\"#__codelineno-8-4\"></a><span class=\"w\"> </span><span class=\"nx\">editorMode</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'VISUAL'</span><span class=\"p\">,</span> </span><span id=\"__span-8-5\"><a id=\"__codelineno-8-5\" name=\"__codelineno-8-5\" href=\"#__codelineno-8-5\"></a><span class=\"p\">});</span> </span></code></pre></div> <p><strong>Request Body Schema:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-9-1\"><a id=\"__codelineno-9-1\" name=\"__codelineno-9-1\" href=\"#__codelineno-9-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-9-2\"><a id=\"__codelineno-9-2\" name=\"__codelineno-9-2\" href=\"#__codelineno-9-2\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"c1\">// Required, min 1 char, max 255 chars</span> </span><span id=\"__span-9-3\"><a id=\"__codelineno-9-3\" name=\"__codelineno-9-3\" href=\"#__codelineno-9-3\"></a><span class=\"w\"> </span><span class=\"nx\">description?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"c1\">// Optional, max 1000 chars</span> </span><span id=\"__span-9-4\"><a id=\"__codelineno-9-4\" name=\"__codelineno-9-4\" href=\"#__codelineno-9-4\"></a><span class=\"w\"> </span><span class=\"nx\">editorMode?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">EditorMode</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"c1\">// Optional, 'VISUAL' or 'CODE' (default: 'VISUAL')</span> </span><span id=\"__span-9-5\"><a id=\"__codelineno-9-5\" name=\"__codelineno-9-5\" href=\"#__codelineno-9-5\"></a><span class=\"p\">}</span> </span></code></pre></div> <p><strong>Response (201 Created):</strong></p> <div class=\"language-json highlight\"><pre><span></span><code><span id=\"__span-10-1\"><a id=\"__codelineno-10-1\" name=\"__codelineno-10-1\" href=\"#__codelineno-10-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-10-2\"><a id=\"__codelineno-10-2\" name=\"__codelineno-10-2\" href=\"#__codelineno-10-2\"></a><span class=\"w\"> </span><span class=\"nt\">\"id\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"page_abc123\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-3\"><a id=\"__codelineno-10-3\" name=\"__codelineno-10-3\" href=\"#__codelineno-10-3\"></a><span class=\"w\"> </span><span class=\"nt\">\"title\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"About Our Campaign\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-4\"><a id=\"__codelineno-10-4\" name=\"__codelineno-10-4\" href=\"#__codelineno-10-4\"></a><span class=\"w\"> </span><span class=\"nt\">\"description\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"Learn about our mission and values\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-5\"><a id=\"__codelineno-10-5\" name=\"__codelineno-10-5\" href=\"#__codelineno-10-5\"></a><span class=\"w\"> </span><span class=\"nt\">\"slug\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"about-our-campaign\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-6\"><a id=\"__codelineno-10-6\" name=\"__codelineno-10-6\" href=\"#__codelineno-10-6\"></a><span class=\"w\"> </span><span class=\"nt\">\"editorMode\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"VISUAL\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-7\"><a id=\"__codelineno-10-7\" name=\"__codelineno-10-7\" href=\"#__codelineno-10-7\"></a><span class=\"w\"> </span><span class=\"nt\">\"published\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">false</span><span class=\"p\">,</span> </span><span id=\"__span-10-8\"><a id=\"__codelineno-10-8\" name=\"__codelineno-10-8\" href=\"#__codelineno-10-8\"></a><span class=\"w\"> </span><span class=\"nt\">\"htmlContent\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-9\"><a id=\"__codelineno-10-9\" name=\"__codelineno-10-9\" href=\"#__codelineno-10-9\"></a><span class=\"w\"> </span><span class=\"nt\">\"cssContent\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-10\"><a id=\"__codelineno-10-10\" name=\"__codelineno-10-10\" href=\"#__codelineno-10-10\"></a><span class=\"w\"> </span><span class=\"nt\">\"jsContent\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-11\"><a id=\"__codelineno-10-11\" name=\"__codelineno-10-11\" href=\"#__codelineno-10-11\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsPath\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">null</span><span class=\"p\">,</span> </span><span id=\"__span-10-12\"><a id=\"__codelineno-10-12\" name=\"__codelineno-10-12\" href=\"#__codelineno-10-12\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsStubPath\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">null</span><span class=\"p\">,</span> </span><span id=\"__span-10-13\"><a id=\"__codelineno-10-13\" name=\"__codelineno-10-13\" href=\"#__codelineno-10-13\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsExportMode\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"THEMED\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-14\"><a id=\"__codelineno-10-14\" name=\"__codelineno-10-14\" href=\"#__codelineno-10-14\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsHideNav\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">false</span><span class=\"p\">,</span> </span><span id=\"__span-10-15\"><a id=\"__codelineno-10-15\" name=\"__codelineno-10-15\" href=\"#__codelineno-10-15\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsHideToc\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">false</span><span class=\"p\">,</span> </span><span id=\"__span-10-16\"><a id=\"__codelineno-10-16\" name=\"__codelineno-10-16\" href=\"#__codelineno-10-16\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsSkipExport\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">false</span><span class=\"p\">,</span> </span><span id=\"__span-10-17\"><a id=\"__codelineno-10-17\" name=\"__codelineno-10-17\" href=\"#__codelineno-10-17\"></a><span class=\"w\"> </span><span class=\"nt\">\"seoTitle\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">null</span><span class=\"p\">,</span> </span><span id=\"__span-10-18\"><a id=\"__codelineno-10-18\" name=\"__codelineno-10-18\" href=\"#__codelineno-10-18\"></a><span class=\"w\"> </span><span class=\"nt\">\"seoDescription\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">null</span><span class=\"p\">,</span> </span><span id=\"__span-10-19\"><a id=\"__codelineno-10-19\" name=\"__codelineno-10-19\" href=\"#__codelineno-10-19\"></a><span class=\"w\"> </span><span class=\"nt\">\"seoImage\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">null</span><span class=\"p\">,</span> </span><span id=\"__span-10-20\"><a id=\"__codelineno-10-20\" name=\"__codelineno-10-20\" href=\"#__codelineno-10-20\"></a><span class=\"w\"> </span><span class=\"nt\">\"createdAt\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"2026-02-11T10:45:00.000Z\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-21\"><a id=\"__codelineno-10-21\" name=\"__codelineno-10-21\" href=\"#__codelineno-10-21\"></a><span class=\"w\"> </span><span class=\"nt\">\"updatedAt\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"2026-02-11T10:45:00.000Z\"</span> </span><span id=\"__span-10-22\"><a id=\"__codelineno-10-22\" name=\"__codelineno-10-22\" href=\"#__codelineno-10-22\"></a><span class=\"p\">}</span> </span></code></pre></div> <p><strong>Backend Workflow:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-11-1\"><a id=\"__codelineno-11-1\" name=\"__codelineno-11-1\" href=\"#__codelineno-11-1\"></a><span class=\"c1\">// 1. Generate slug from title</span> </span><span id=\"__span-11-2\"><a id=\"__codelineno-11-2\" name=\"__codelineno-11-2\" href=\"#__codelineno-11-2\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">slug</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">generateSlug</span><span class=\"p\">(</span><span class=\"nx\">title</span><span class=\"p\">);</span><span class=\"w\"> </span><span class=\"c1\">// \"About Our Campaign\" \u2192 \"about-our-campaign\"</span> </span><span id=\"__span-11-3\"><a id=\"__codelineno-11-3\" name=\"__codelineno-11-3\" href=\"#__codelineno-11-3\"></a> </span><span id=\"__span-11-4\"><a id=\"__codelineno-11-4\" name=\"__codelineno-11-4\" href=\"#__codelineno-11-4\"></a><span class=\"c1\">// 2. Ensure slug is unique</span> </span><span id=\"__span-11-5\"><a id=\"__codelineno-11-5\" name=\"__codelineno-11-5\" href=\"#__codelineno-11-5\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">existingPage</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">prisma</span><span class=\"p\">.</span><span class=\"nx\">landingPage</span><span class=\"p\">.</span><span class=\"nx\">findUnique</span><span class=\"p\">({</span><span class=\"w\"> </span><span class=\"nx\">where</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">slug</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"p\">});</span> </span><span id=\"__span-11-6\"><a id=\"__codelineno-11-6\" name=\"__codelineno-11-6\" href=\"#__codelineno-11-6\"></a><span class=\"k\">if</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">existingPage</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-11-7\"><a id=\"__codelineno-11-7\" name=\"__codelineno-11-7\" href=\"#__codelineno-11-7\"></a><span class=\"w\"> </span><span class=\"nx\">slug</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"sb\">`</span><span class=\"si\">${</span><span class=\"nx\">slug</span><span class=\"si\">}</span><span class=\"sb\">-2`</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"c1\">// Append -2 if duplicate (or -3, -4, etc.)</span> </span><span id=\"__span-11-8\"><a id=\"__codelineno-11-8\" name=\"__codelineno-11-8\" href=\"#__codelineno-11-8\"></a><span class=\"p\">}</span> </span><span id=\"__span-11-9\"><a id=\"__codelineno-11-9\" name=\"__codelineno-11-9\" href=\"#__codelineno-11-9\"></a> </span><span id=\"__span-11-10\"><a id=\"__codelineno-11-10\" name=\"__codelineno-11-10\" href=\"#__codelineno-11-10\"></a><span class=\"c1\">// 3. Create page record</span> </span><span id=\"__span-11-11\"><a id=\"__codelineno-11-11\" name=\"__codelineno-11-11\" href=\"#__codelineno-11-11\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">prisma</span><span class=\"p\">.</span><span class=\"nx\">landingPage</span><span class=\"p\">.</span><span class=\"nx\">create</span><span class=\"p\">({</span> </span><span id=\"__span-11-12\"><a id=\"__codelineno-11-12\" name=\"__codelineno-11-12\" href=\"#__codelineno-11-12\"></a><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-11-13\"><a id=\"__codelineno-11-13\" name=\"__codelineno-11-13\" href=\"#__codelineno-11-13\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"p\">,</span> </span><span id=\"__span-11-14\"><a id=\"__codelineno-11-14\" name=\"__codelineno-11-14\" href=\"#__codelineno-11-14\"></a><span class=\"w\"> </span><span class=\"nx\">description</span><span class=\"p\">,</span> </span><span id=\"__span-11-15\"><a id=\"__codelineno-11-15\" name=\"__codelineno-11-15\" href=\"#__codelineno-11-15\"></a><span class=\"w\"> </span><span class=\"nx\">slug</span><span class=\"p\">,</span> </span><span id=\"__span-11-16\"><a id=\"__codelineno-11-16\" name=\"__codelineno-11-16\" href=\"#__codelineno-11-16\"></a><span class=\"w\"> </span><span class=\"nx\">editorMode</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">editorMode</span><span class=\"w\"> </span><span class=\"o\">||</span><span class=\"w\"> </span><span class=\"s1\">'VISUAL'</span><span class=\"p\">,</span> </span><span id=\"__span-11-17\"><a id=\"__codelineno-11-17\" name=\"__codelineno-11-17\" href=\"#__codelineno-11-17\"></a><span class=\"w\"> </span><span class=\"nx\">published</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">false</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"c1\">// Always start as draft</span> </span><span id=\"__span-11-18\"><a id=\"__codelineno-11-18\" name=\"__codelineno-11-18\" href=\"#__codelineno-11-18\"></a><span class=\"w\"> </span><span class=\"nx\">htmlContent</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">''</span><span class=\"p\">,</span> </span><span id=\"__span-11-19\"><a id=\"__codelineno-11-19\" name=\"__codelineno-11-19\" href=\"#__codelineno-11-19\"></a><span class=\"w\"> </span><span class=\"nx\">cssContent</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">''</span><span class=\"p\">,</span> </span><span id=\"__span-11-20\"><a id=\"__codelineno-11-20\" name=\"__codelineno-11-20\" href=\"#__codelineno-11-20\"></a><span class=\"w\"> </span><span class=\"nx\">jsContent</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">''</span><span class=\"p\">,</span> </span><span id=\"__span-11-21\"><a id=\"__codelineno-11-21\" name=\"__codelineno-11-21\" href=\"#__codelineno-11-21\"></a><span class=\"w\"> </span><span class=\"c1\">// ... MkDocs defaults</span> </span><span id=\"__span-11-22\"><a id=\"__codelineno-11-22\" name=\"__codelineno-11-22\" href=\"#__codelineno-11-22\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-11-23\"><a id=\"__codelineno-11-23\" name=\"__codelineno-11-23\" href=\"#__codelineno-11-23\"></a><span class=\"p\">});</span> </span><span id=\"__span-11-24\"><a id=\"__codelineno-11-24\" name=\"__codelineno-11-24\" href=\"#__codelineno-11-24\"></a> </span><span id=\"__span-11-25\"><a id=\"__codelineno-11-25\" name=\"__codelineno-11-25\" href=\"#__codelineno-11-25\"></a><span class=\"k\">return</span><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"p\">;</span> </span></code></pre></div> <h3 id=\"update-page-settings\">Update Page Settings<a class=\"headerlink\" href=\"#update-page-settings\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Request:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-12-1\"><a id=\"__codelineno-12-1\" name=\"__codelineno-12-1\" href=\"#__codelineno-12-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">pageId</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"s1\">'page_abc123'</span><span class=\"p\">;</span> </span><span id=\"__span-12-2\"><a id=\"__codelineno-12-2\" name=\"__codelineno-12-2\" href=\"#__codelineno-12-2\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">updates</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-12-3\"><a id=\"__codelineno-12-3\" name=\"__codelineno-12-3\" href=\"#__codelineno-12-3\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'About Our Campaign (Updated)'</span><span class=\"p\">,</span> </span><span id=\"__span-12-4\"><a id=\"__codelineno-12-4\" name=\"__codelineno-12-4\" href=\"#__codelineno-12-4\"></a><span class=\"w\"> </span><span class=\"nx\">description</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Updated description'</span><span class=\"p\">,</span> </span><span id=\"__span-12-5\"><a id=\"__codelineno-12-5\" name=\"__codelineno-12-5\" href=\"#__codelineno-12-5\"></a><span class=\"w\"> </span><span class=\"nx\">seoTitle</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'About Our Campaign | Campaign Name 2026'</span><span class=\"p\">,</span> </span><span id=\"__span-12-6\"><a id=\"__codelineno-12-6\" name=\"__codelineno-12-6\" href=\"#__codelineno-12-6\"></a><span class=\"w\"> </span><span class=\"nx\">seoDescription</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Learn about our 2026 campaign mission'</span><span class=\"p\">,</span> </span><span id=\"__span-12-7\"><a id=\"__codelineno-12-7\" name=\"__codelineno-12-7\" href=\"#__codelineno-12-7\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsPath</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'about.html'</span><span class=\"p\">,</span> </span><span id=\"__span-12-8\"><a id=\"__codelineno-12-8\" name=\"__codelineno-12-8\" href=\"#__codelineno-12-8\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsExportMode</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'THEMED'</span><span class=\"p\">,</span> </span><span id=\"__span-12-9\"><a id=\"__codelineno-12-9\" name=\"__codelineno-12-9\" href=\"#__codelineno-12-9\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsHideNav</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">true</span><span class=\"p\">,</span> </span><span id=\"__span-12-10\"><a id=\"__codelineno-12-10\" name=\"__codelineno-12-10\" href=\"#__codelineno-12-10\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsHideToc</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">false</span><span class=\"p\">,</span> </span><span id=\"__span-12-11\"><a id=\"__codelineno-12-11\" name=\"__codelineno-12-11\" href=\"#__codelineno-12-11\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsSkipExport</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">false</span><span class=\"p\">,</span> </span><span id=\"__span-12-12\"><a id=\"__codelineno-12-12\" name=\"__codelineno-12-12\" href=\"#__codelineno-12-12\"></a><span class=\"p\">};</span> </span><span id=\"__span-12-13\"><a id=\"__codelineno-12-13\" name=\"__codelineno-12-13\" href=\"#__codelineno-12-13\"></a> </span><span id=\"__span-12-14\"><a id=\"__codelineno-12-14\" name=\"__codelineno-12-14\" href=\"#__codelineno-12-14\"></a><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">put</span><span class=\"p\">(</span><span class=\"sb\">`/pages/</span><span class=\"si\">${</span><span class=\"nx\">pageId</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">updates</span><span class=\"p\">);</span> </span></code></pre></div> <p><strong>Request Body Schema:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-13-1\"><a id=\"__codelineno-13-1\" name=\"__codelineno-13-1\" href=\"#__codelineno-13-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-13-2\"><a id=\"__codelineno-13-2\" name=\"__codelineno-13-2\" href=\"#__codelineno-13-2\"></a><span class=\"w\"> </span><span class=\"nx\">title?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span> </span><span id=\"__span-13-3\"><a id=\"__codelineno-13-3\" name=\"__codelineno-13-3\" href=\"#__codelineno-13-3\"></a><span class=\"w\"> </span><span class=\"nx\">description?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span> </span><span id=\"__span-13-4\"><a id=\"__codelineno-13-4\" name=\"__codelineno-13-4\" href=\"#__codelineno-13-4\"></a><span class=\"w\"> </span><span class=\"nx\">published?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">boolean</span><span class=\"p\">;</span> </span><span id=\"__span-13-5\"><a id=\"__codelineno-13-5\" name=\"__codelineno-13-5\" href=\"#__codelineno-13-5\"></a><span class=\"w\"> </span><span class=\"nx\">seoTitle?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span> </span><span id=\"__span-13-6\"><a id=\"__codelineno-13-6\" name=\"__codelineno-13-6\" href=\"#__codelineno-13-6\"></a><span class=\"w\"> </span><span class=\"nx\">seoDescription?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span> </span><span id=\"__span-13-7\"><a id=\"__codelineno-13-7\" name=\"__codelineno-13-7\" href=\"#__codelineno-13-7\"></a><span class=\"w\"> </span><span class=\"nx\">seoImage?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span> </span><span id=\"__span-13-8\"><a id=\"__codelineno-13-8\" name=\"__codelineno-13-8\" href=\"#__codelineno-13-8\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsPath?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span> </span><span id=\"__span-13-9\"><a id=\"__codelineno-13-9\" name=\"__codelineno-13-9\" href=\"#__codelineno-13-9\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsExportMode</span><span class=\"o\">?:</span><span class=\"w\"> </span><span class=\"s1\">'THEMED'</span><span class=\"w\"> </span><span class=\"o\">|</span><span class=\"w\"> </span><span class=\"s1\">'STANDALONE'</span><span class=\"p\">;</span> </span><span id=\"__span-13-10\"><a id=\"__codelineno-13-10\" name=\"__codelineno-13-10\" href=\"#__codelineno-13-10\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsHideNav?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">boolean</span><span class=\"p\">;</span> </span><span id=\"__span-13-11\"><a id=\"__codelineno-13-11\" name=\"__codelineno-13-11\" href=\"#__codelineno-13-11\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsHideToc?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">boolean</span><span class=\"p\">;</span> </span><span id=\"__span-13-12\"><a id=\"__codelineno-13-12\" name=\"__codelineno-13-12\" href=\"#__codelineno-13-12\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsSkipExport?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">boolean</span><span class=\"p\">;</span> </span><span id=\"__span-13-13\"><a id=\"__codelineno-13-13\" name=\"__codelineno-13-13\" href=\"#__codelineno-13-13\"></a><span class=\"w\"> </span><span class=\"c1\">// Note: htmlContent, cssContent, jsContent updated via editor, not settings modal</span> </span><span id=\"__span-13-14\"><a id=\"__codelineno-13-14\" name=\"__codelineno-13-14\" href=\"#__codelineno-13-14\"></a><span class=\"p\">}</span> </span></code></pre></div> <p><strong>Response (200 OK):</strong></p> <div class=\"language-json highlight\"><pre><span></span><code><span id=\"__span-14-1\"><a id=\"__codelineno-14-1\" name=\"__codelineno-14-1\" href=\"#__codelineno-14-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-14-2\"><a id=\"__codelineno-14-2\" name=\"__codelineno-14-2\" href=\"#__codelineno-14-2\"></a><span class=\"w\"> </span><span class=\"nt\">\"id\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"page_abc123\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-3\"><a id=\"__codelineno-14-3\" name=\"__codelineno-14-3\" href=\"#__codelineno-14-3\"></a><span class=\"w\"> </span><span class=\"nt\">\"title\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"About Our Campaign (Updated)\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-4\"><a id=\"__codelineno-14-4\" name=\"__codelineno-14-4\" href=\"#__codelineno-14-4\"></a><span class=\"w\"> </span><span class=\"nt\">\"description\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"Updated description\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-5\"><a id=\"__codelineno-14-5\" name=\"__codelineno-14-5\" href=\"#__codelineno-14-5\"></a><span class=\"w\"> </span><span class=\"nt\">\"slug\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"about-our-campaign\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-6\"><a id=\"__codelineno-14-6\" name=\"__codelineno-14-6\" href=\"#__codelineno-14-6\"></a><span class=\"w\"> </span><span class=\"nt\">\"editorMode\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"VISUAL\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-7\"><a id=\"__codelineno-14-7\" name=\"__codelineno-14-7\" href=\"#__codelineno-14-7\"></a><span class=\"w\"> </span><span class=\"nt\">\"published\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">true</span><span class=\"p\">,</span> </span><span id=\"__span-14-8\"><a id=\"__codelineno-14-8\" name=\"__codelineno-14-8\" href=\"#__codelineno-14-8\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsPath\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"about.html\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-9\"><a id=\"__codelineno-14-9\" name=\"__codelineno-14-9\" href=\"#__codelineno-14-9\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsExportMode\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"THEMED\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-10\"><a id=\"__codelineno-14-10\" name=\"__codelineno-14-10\" href=\"#__codelineno-14-10\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsHideNav\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">true</span><span class=\"p\">,</span> </span><span id=\"__span-14-11\"><a id=\"__codelineno-14-11\" name=\"__codelineno-14-11\" href=\"#__codelineno-14-11\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsHideToc\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">false</span><span class=\"p\">,</span> </span><span id=\"__span-14-12\"><a id=\"__codelineno-14-12\" name=\"__codelineno-14-12\" href=\"#__codelineno-14-12\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsSkipExport\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">false</span><span class=\"p\">,</span> </span><span id=\"__span-14-13\"><a id=\"__codelineno-14-13\" name=\"__codelineno-14-13\" href=\"#__codelineno-14-13\"></a><span class=\"w\"> </span><span class=\"nt\">\"seoTitle\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"About Our Campaign | Campaign Name 2026\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-14\"><a id=\"__codelineno-14-14\" name=\"__codelineno-14-14\" href=\"#__codelineno-14-14\"></a><span class=\"w\"> </span><span class=\"nt\">\"seoDescription\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"Learn about our 2026 campaign mission\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-15\"><a id=\"__codelineno-14-15\" name=\"__codelineno-14-15\" href=\"#__codelineno-14-15\"></a><span class=\"w\"> </span><span class=\"nt\">\"seoImage\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">null</span><span class=\"p\">,</span> </span><span id=\"__span-14-16\"><a id=\"__codelineno-14-16\" name=\"__codelineno-14-16\" href=\"#__codelineno-14-16\"></a><span class=\"w\"> </span><span class=\"nt\">\"createdAt\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"2026-01-15T10:30:00.000Z\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-17\"><a id=\"__codelineno-14-17\" name=\"__codelineno-14-17\" href=\"#__codelineno-14-17\"></a><span class=\"w\"> </span><span class=\"nt\">\"updatedAt\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"2026-02-11T11:00:00.000Z\"</span> </span><span id=\"__span-14-18\"><a id=\"__codelineno-14-18\" name=\"__codelineno-14-18\" href=\"#__codelineno-14-18\"></a><span class=\"p\">}</span> </span></code></pre></div> <p><strong>Important:</strong> Slug is NOT updated when title changes (prevents breaking /p/:slug URLs).</p> <h3 id=\"toggle-published-status\">Toggle Published Status<a class=\"headerlink\" href=\"#toggle-published-status\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Request:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-15-1\"><a id=\"__codelineno-15-1\" name=\"__codelineno-15-1\" href=\"#__codelineno-15-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">id</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'page_abc123'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">published</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">true</span><span class=\"w\"> </span><span class=\"p\">};</span> </span><span id=\"__span-15-2\"><a id=\"__codelineno-15-2\" name=\"__codelineno-15-2\" href=\"#__codelineno-15-2\"></a> </span><span id=\"__span-15-3\"><a id=\"__codelineno-15-3\" name=\"__codelineno-15-3\" href=\"#__codelineno-15-3\"></a><span class=\"c1\">// Toggle: if published, unpublish; if unpublished, publish</span> </span><span id=\"__span-15-4\"><a id=\"__codelineno-15-4\" name=\"__codelineno-15-4\" href=\"#__codelineno-15-4\"></a><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">put</span><span class=\"p\">(</span><span class=\"sb\">`/pages/</span><span class=\"si\">${</span><span class=\"nx\">page</span><span class=\"p\">.</span><span class=\"nx\">id</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-15-5\"><a id=\"__codelineno-15-5\" name=\"__codelineno-15-5\" href=\"#__codelineno-15-5\"></a><span class=\"w\"> </span><span class=\"nx\">published</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"o\">!</span><span class=\"nx\">page</span><span class=\"p\">.</span><span class=\"nx\">published</span><span class=\"p\">,</span> </span><span id=\"__span-15-6\"><a id=\"__codelineno-15-6\" name=\"__codelineno-15-6\" href=\"#__codelineno-15-6\"></a><span class=\"p\">});</span> </span></code></pre></div> <p><strong>Response (200 OK):</strong></p> <p>Same as Update Page Settings response.</p> <p><strong>Backend Side Effects:</strong></p> <p>When page is published (<code>published: true</code>): - If <code>mkdocsSkipExport: false</code>, export override file to <code>mkdocs/docs/overrides/</code> - If <code>mkdocsStubPath</code> set, create Markdown stub at <code>mkdocs/docs/{stubPath}</code> - Page becomes accessible at <code>/p/:slug</code></p> <p>When page is unpublished (<code>published: false</code>): - Override file remains (not deleted automatically) - Page becomes inaccessible at <code>/p/:slug</code> (404 error)</p> <h3 id=\"delete-page\">Delete Page<a class=\"headerlink\" href=\"#delete-page\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Request:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-16-1\"><a id=\"__codelineno-16-1\" name=\"__codelineno-16-1\" href=\"#__codelineno-16-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">pageId</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"s1\">'page_abc123'</span><span class=\"p\">;</span> </span><span id=\"__span-16-2\"><a id=\"__codelineno-16-2\" name=\"__codelineno-16-2\" href=\"#__codelineno-16-2\"></a><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"ow\">delete</span><span class=\"p\">(</span><span class=\"sb\">`/pages/</span><span class=\"si\">${</span><span class=\"nx\">pageId</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">);</span> </span></code></pre></div> <p><strong>Response (200 OK):</strong></p> <div class=\"language-json highlight\"><pre><span></span><code><span id=\"__span-17-1\"><a id=\"__codelineno-17-1\" name=\"__codelineno-17-1\" href=\"#__codelineno-17-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-17-2\"><a id=\"__codelineno-17-2\" name=\"__codelineno-17-2\" href=\"#__codelineno-17-2\"></a><span class=\"w\"> </span><span class=\"nt\">\"message\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"Page deleted\"</span> </span><span id=\"__span-17-3\"><a id=\"__codelineno-17-3\" name=\"__codelineno-17-3\" href=\"#__codelineno-17-3\"></a><span class=\"p\">}</span> </span></code></pre></div> <p><strong>Backend Workflow:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-18-1\"><a id=\"__codelineno-18-1\" name=\"__codelineno-18-1\" href=\"#__codelineno-18-1\"></a><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">prisma</span><span class=\"p\">.</span><span class=\"nx\">landingPage</span><span class=\"p\">.</span><span class=\"ow\">delete</span><span class=\"p\">({</span> </span><span id=\"__span-18-2\"><a id=\"__codelineno-18-2\" name=\"__codelineno-18-2\" href=\"#__codelineno-18-2\"></a><span class=\"w\"> </span><span class=\"nx\">where</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">id</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">pageId</span><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-18-3\"><a id=\"__codelineno-18-3\" name=\"__codelineno-18-3\" href=\"#__codelineno-18-3\"></a><span class=\"p\">});</span> </span></code></pre></div> <p><strong>Important:</strong> Override files and Markdown stubs are NOT automatically deleted. Manual cleanup required:</p> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-19-1\"><a id=\"__codelineno-19-1\" name=\"__codelineno-19-1\" href=\"#__codelineno-19-1\"></a>rm<span class=\"w\"> </span>mkdocs/docs/overrides/about.html </span><span id=\"__span-19-2\"><a id=\"__codelineno-19-2\" name=\"__codelineno-19-2\" href=\"#__codelineno-19-2\"></a>rm<span class=\"w\"> </span>mkdocs/docs/about.md </span></code></pre></div> <h3 id=\"sync-mkdocs-overrides\">Sync MkDocs Overrides<a class=\"headerlink\" href=\"#sync-mkdocs-overrides\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Request:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-20-1\"><a id=\"__codelineno-20-1\" name=\"__codelineno-20-1\" href=\"#__codelineno-20-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">post</span><span class=\"o\"><</span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">imported</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">updated</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">stubs</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"o\">></span><span class=\"p\">(</span><span class=\"s1\">'/pages/sync'</span><span class=\"p\">);</span> </span></code></pre></div> <p><strong>Response (200 OK):</strong></p> <div class=\"language-json highlight\"><pre><span></span><code><span id=\"__span-21-1\"><a id=\"__codelineno-21-1\" name=\"__codelineno-21-1\" href=\"#__codelineno-21-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-21-2\"><a id=\"__codelineno-21-2\" name=\"__codelineno-21-2\" href=\"#__codelineno-21-2\"></a><span class=\"w\"> </span><span class=\"nt\">\"imported\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">3</span><span class=\"p\">,</span> </span><span id=\"__span-21-3\"><a id=\"__codelineno-21-3\" name=\"__codelineno-21-3\" href=\"#__codelineno-21-3\"></a><span class=\"w\"> </span><span class=\"nt\">\"updated\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">2</span><span class=\"p\">,</span> </span><span id=\"__span-21-4\"><a id=\"__codelineno-21-4\" name=\"__codelineno-21-4\" href=\"#__codelineno-21-4\"></a><span class=\"w\"> </span><span class=\"nt\">\"stubs\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">1</span><span class=\"p\">,</span> </span><span id=\"__span-21-5\"><a id=\"__codelineno-21-5\" name=\"__codelineno-21-5\" href=\"#__codelineno-21-5\"></a><span class=\"w\"> </span><span class=\"nt\">\"message\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"Synced: 3 imported, 2 updated, 1 stubs created\"</span> </span><span id=\"__span-21-6\"><a id=\"__codelineno-21-6\" name=\"__codelineno-21-6\" href=\"#__codelineno-21-6\"></a><span class=\"p\">}</span> </span></code></pre></div> <p><strong>Response Fields:</strong> - <code>imported</code> (number): Number of new pages created from override files - <code>updated</code> (number): Number of existing pages updated from override files - <code>stubs</code> (number): Number of Markdown stubs created for new pages</p> <p><strong>Backend Workflow:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-22-1\"><a id=\"__codelineno-22-1\" name=\"__codelineno-22-1\" href=\"#__codelineno-22-1\"></a><span class=\"c1\">// 1. Scan mkdocs/docs/overrides/ directory</span> </span><span id=\"__span-22-2\"><a id=\"__codelineno-22-2\" name=\"__codelineno-22-2\" href=\"#__codelineno-22-2\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">overrideFiles</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">fs</span><span class=\"p\">.</span><span class=\"nx\">readdirSync</span><span class=\"p\">(</span><span class=\"s1\">'mkdocs/docs/overrides/'</span><span class=\"p\">);</span> </span><span id=\"__span-22-3\"><a id=\"__codelineno-22-3\" name=\"__codelineno-22-3\" href=\"#__codelineno-22-3\"></a> </span><span id=\"__span-22-4\"><a id=\"__codelineno-22-4\" name=\"__codelineno-22-4\" href=\"#__codelineno-22-4\"></a><span class=\"c1\">// 2. For each override file</span> </span><span id=\"__span-22-5\"><a id=\"__codelineno-22-5\" name=\"__codelineno-22-5\" href=\"#__codelineno-22-5\"></a><span class=\"k\">for</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">file</span><span class=\"w\"> </span><span class=\"k\">of</span><span class=\"w\"> </span><span class=\"nx\">overrideFiles</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-22-6\"><a id=\"__codelineno-22-6\" name=\"__codelineno-22-6\" href=\"#__codelineno-22-6\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">content</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">fs</span><span class=\"p\">.</span><span class=\"nx\">readFileSync</span><span class=\"p\">(</span><span class=\"sb\">`mkdocs/docs/overrides/</span><span class=\"si\">${</span><span class=\"nx\">file</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"s1\">'utf-8'</span><span class=\"p\">);</span> </span><span id=\"__span-22-7\"><a id=\"__codelineno-22-7\" name=\"__codelineno-22-7\" href=\"#__codelineno-22-7\"></a> </span><span id=\"__span-22-8\"><a id=\"__codelineno-22-8\" name=\"__codelineno-22-8\" href=\"#__codelineno-22-8\"></a><span class=\"w\"> </span><span class=\"c1\">// 3. Check if page record exists for this override</span> </span><span id=\"__span-22-9\"><a id=\"__codelineno-22-9\" name=\"__codelineno-22-9\" href=\"#__codelineno-22-9\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">existingPage</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">prisma</span><span class=\"p\">.</span><span class=\"nx\">landingPage</span><span class=\"p\">.</span><span class=\"nx\">findFirst</span><span class=\"p\">({</span> </span><span id=\"__span-22-10\"><a id=\"__codelineno-22-10\" name=\"__codelineno-22-10\" href=\"#__codelineno-22-10\"></a><span class=\"w\"> </span><span class=\"nx\">where</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">mkdocsPath</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">file</span><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-22-11\"><a id=\"__codelineno-22-11\" name=\"__codelineno-22-11\" href=\"#__codelineno-22-11\"></a><span class=\"w\"> </span><span class=\"p\">});</span> </span><span id=\"__span-22-12\"><a id=\"__codelineno-22-12\" name=\"__codelineno-22-12\" href=\"#__codelineno-22-12\"></a> </span><span id=\"__span-22-13\"><a id=\"__codelineno-22-13\" name=\"__codelineno-22-13\" href=\"#__codelineno-22-13\"></a><span class=\"w\"> </span><span class=\"k\">if</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">existingPage</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-22-14\"><a id=\"__codelineno-22-14\" name=\"__codelineno-22-14\" href=\"#__codelineno-22-14\"></a><span class=\"w\"> </span><span class=\"c1\">// 4a. Update existing page if content changed</span> </span><span id=\"__span-22-15\"><a id=\"__codelineno-22-15\" name=\"__codelineno-22-15\" href=\"#__codelineno-22-15\"></a><span class=\"w\"> </span><span class=\"k\">if</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">existingPage</span><span class=\"p\">.</span><span class=\"nx\">htmlContent</span><span class=\"w\"> </span><span class=\"o\">!==</span><span class=\"w\"> </span><span class=\"nx\">content</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-22-16\"><a id=\"__codelineno-22-16\" name=\"__codelineno-22-16\" href=\"#__codelineno-22-16\"></a><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">prisma</span><span class=\"p\">.</span><span class=\"nx\">landingPage</span><span class=\"p\">.</span><span class=\"nx\">update</span><span class=\"p\">({</span> </span><span id=\"__span-22-17\"><a id=\"__codelineno-22-17\" name=\"__codelineno-22-17\" href=\"#__codelineno-22-17\"></a><span class=\"w\"> </span><span class=\"nx\">where</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">id</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">existingPage.id</span><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-22-18\"><a id=\"__codelineno-22-18\" name=\"__codelineno-22-18\" href=\"#__codelineno-22-18\"></a><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">htmlContent</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">content</span><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-22-19\"><a id=\"__codelineno-22-19\" name=\"__codelineno-22-19\" href=\"#__codelineno-22-19\"></a><span class=\"w\"> </span><span class=\"p\">});</span> </span><span id=\"__span-22-20\"><a id=\"__codelineno-22-20\" name=\"__codelineno-22-20\" href=\"#__codelineno-22-20\"></a><span class=\"w\"> </span><span class=\"nx\">updated</span><span class=\"o\">++</span><span class=\"p\">;</span> </span><span id=\"__span-22-21\"><a id=\"__codelineno-22-21\" name=\"__codelineno-22-21\" href=\"#__codelineno-22-21\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-22-22\"><a id=\"__codelineno-22-22\" name=\"__codelineno-22-22\" href=\"#__codelineno-22-22\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">else</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-22-23\"><a id=\"__codelineno-22-23\" name=\"__codelineno-22-23\" href=\"#__codelineno-22-23\"></a><span class=\"w\"> </span><span class=\"c1\">// 4b. Create new page stub for untracked override</span> </span><span id=\"__span-22-24\"><a id=\"__codelineno-22-24\" name=\"__codelineno-22-24\" href=\"#__codelineno-22-24\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">prisma</span><span class=\"p\">.</span><span class=\"nx\">landingPage</span><span class=\"p\">.</span><span class=\"nx\">create</span><span class=\"p\">({</span> </span><span id=\"__span-22-25\"><a id=\"__codelineno-22-25\" name=\"__codelineno-22-25\" href=\"#__codelineno-22-25\"></a><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-22-26\"><a id=\"__codelineno-22-26\" name=\"__codelineno-22-26\" href=\"#__codelineno-22-26\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">extractTitleFromHtml</span><span class=\"p\">(</span><span class=\"nx\">content</span><span class=\"p\">),</span><span class=\"w\"> </span><span class=\"c1\">// Parse <title> tag</span> </span><span id=\"__span-22-27\"><a id=\"__codelineno-22-27\" name=\"__codelineno-22-27\" href=\"#__codelineno-22-27\"></a><span class=\"w\"> </span><span class=\"nx\">slug</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">file.replace</span><span class=\"p\">(</span><span class=\"s1\">'.html'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"s1\">''</span><span class=\"p\">),</span> </span><span id=\"__span-22-28\"><a id=\"__codelineno-22-28\" name=\"__codelineno-22-28\" href=\"#__codelineno-22-28\"></a><span class=\"w\"> </span><span class=\"nx\">editorMode</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'CODE'</span><span class=\"p\">,</span> </span><span id=\"__span-22-29\"><a id=\"__codelineno-22-29\" name=\"__codelineno-22-29\" href=\"#__codelineno-22-29\"></a><span class=\"w\"> </span><span class=\"nx\">published</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">false</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"c1\">// Start as draft</span> </span><span id=\"__span-22-30\"><a id=\"__codelineno-22-30\" name=\"__codelineno-22-30\" href=\"#__codelineno-22-30\"></a><span class=\"w\"> </span><span class=\"nx\">htmlContent</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">content</span><span class=\"p\">,</span> </span><span id=\"__span-22-31\"><a id=\"__codelineno-22-31\" name=\"__codelineno-22-31\" href=\"#__codelineno-22-31\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsPath</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">file</span><span class=\"p\">,</span> </span><span id=\"__span-22-32\"><a id=\"__codelineno-22-32\" name=\"__codelineno-22-32\" href=\"#__codelineno-22-32\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-22-33\"><a id=\"__codelineno-22-33\" name=\"__codelineno-22-33\" href=\"#__codelineno-22-33\"></a><span class=\"w\"> </span><span class=\"p\">});</span> </span><span id=\"__span-22-34\"><a id=\"__codelineno-22-34\" name=\"__codelineno-22-34\" href=\"#__codelineno-22-34\"></a><span class=\"w\"> </span><span class=\"nx\">imported</span><span class=\"o\">++</span><span class=\"p\">;</span> </span><span id=\"__span-22-35\"><a id=\"__codelineno-22-35\" name=\"__codelineno-22-35\" href=\"#__codelineno-22-35\"></a> </span><span id=\"__span-22-36\"><a id=\"__codelineno-22-36\" name=\"__codelineno-22-36\" href=\"#__codelineno-22-36\"></a><span class=\"w\"> </span><span class=\"c1\">// 5. Create Markdown stub if needed</span> </span><span id=\"__span-22-37\"><a id=\"__codelineno-22-37\" name=\"__codelineno-22-37\" href=\"#__codelineno-22-37\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">stubPath</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"sb\">`</span><span class=\"si\">${</span><span class=\"nx\">file</span><span class=\"p\">.</span><span class=\"nx\">replace</span><span class=\"p\">(</span><span class=\"s1\">'.html'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"s1\">'.md'</span><span class=\"p\">)</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">;</span> </span><span id=\"__span-22-38\"><a id=\"__codelineno-22-38\" name=\"__codelineno-22-38\" href=\"#__codelineno-22-38\"></a><span class=\"w\"> </span><span class=\"nx\">fs</span><span class=\"p\">.</span><span class=\"nx\">writeFileSync</span><span class=\"p\">(</span><span class=\"sb\">`mkdocs/docs/</span><span class=\"si\">${</span><span class=\"nx\">stubPath</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"sb\">`---\\ntemplate: </span><span class=\"si\">${</span><span class=\"nx\">file</span><span class=\"si\">}</span><span class=\"sb\">\\n---\\n`</span><span class=\"p\">);</span> </span><span id=\"__span-22-39\"><a id=\"__codelineno-22-39\" name=\"__codelineno-22-39\" href=\"#__codelineno-22-39\"></a><span class=\"w\"> </span><span class=\"nx\">stubs</span><span class=\"o\">++</span><span class=\"p\">;</span> </span><span id=\"__span-22-40\"><a id=\"__codelineno-22-40\" name=\"__codelineno-22-40\" href=\"#__codelineno-22-40\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-22-41\"><a id=\"__codelineno-22-41\" name=\"__codelineno-22-41\" href=\"#__codelineno-22-41\"></a><span class=\"p\">}</span> </span><span id=\"__span-22-42\"><a id=\"__codelineno-22-42\" name=\"__codelineno-22-42\" href=\"#__codelineno-22-42\"></a> </span><span id=\"__span-22-43\"><a id=\"__codelineno-22-43\" name=\"__codelineno-22-43\" href=\"#__codelineno-22-43\"></a><span class=\"k\">return</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">imported</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">updated</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">stubs</span><span class=\"w\"> </span><span class=\"p\">};</span> </span></code></pre></div> <h3 id=\"validate-mkdocs-exports\">Validate MkDocs Exports<a class=\"headerlink\" href=\"#validate-mkdocs-exports\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Request:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-23-1\"><a id=\"__codelineno-23-1\" name=\"__codelineno-23-1\" href=\"#__codelineno-23-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">post</span><span class=\"o\"><</span><span class=\"p\">{</span> </span><span id=\"__span-23-2\"><a id=\"__codelineno-23-2\" name=\"__codelineno-23-2\" href=\"#__codelineno-23-2\"></a><span class=\"w\"> </span><span class=\"nx\">validated</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"p\">;</span> </span><span id=\"__span-23-3\"><a id=\"__codelineno-23-3\" name=\"__codelineno-23-3\" href=\"#__codelineno-23-3\"></a><span class=\"w\"> </span><span class=\"nx\">repaired</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"p\">;</span> </span><span id=\"__span-23-4\"><a id=\"__codelineno-23-4\" name=\"__codelineno-23-4\" href=\"#__codelineno-23-4\"></a><span class=\"w\"> </span><span class=\"nx\">errors</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">Array</span><span class=\"o\"><</span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">pageId</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">slug</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">error</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"o\">></span><span class=\"p\">;</span> </span><span id=\"__span-23-5\"><a id=\"__codelineno-23-5\" name=\"__codelineno-23-5\" href=\"#__codelineno-23-5\"></a><span class=\"p\">}</span><span class=\"o\">></span><span class=\"p\">(</span><span class=\"s1\">'/pages/validate'</span><span class=\"p\">);</span> </span></code></pre></div> <p><strong>Response (200 OK):</strong></p> <div class=\"language-json highlight\"><pre><span></span><code><span id=\"__span-24-1\"><a id=\"__codelineno-24-1\" name=\"__codelineno-24-1\" href=\"#__codelineno-24-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-24-2\"><a id=\"__codelineno-24-2\" name=\"__codelineno-24-2\" href=\"#__codelineno-24-2\"></a><span class=\"w\"> </span><span class=\"nt\">\"validated\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">24</span><span class=\"p\">,</span> </span><span id=\"__span-24-3\"><a id=\"__codelineno-24-3\" name=\"__codelineno-24-3\" href=\"#__codelineno-24-3\"></a><span class=\"w\"> </span><span class=\"nt\">\"repaired\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">2</span><span class=\"p\">,</span> </span><span id=\"__span-24-4\"><a id=\"__codelineno-24-4\" name=\"__codelineno-24-4\" href=\"#__codelineno-24-4\"></a><span class=\"w\"> </span><span class=\"nt\">\"errors\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"p\">[</span> </span><span id=\"__span-24-5\"><a id=\"__codelineno-24-5\" name=\"__codelineno-24-5\" href=\"#__codelineno-24-5\"></a><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-24-6\"><a id=\"__codelineno-24-6\" name=\"__codelineno-24-6\" href=\"#__codelineno-24-6\"></a><span class=\"w\"> </span><span class=\"nt\">\"pageId\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"page_xyz789\"</span><span class=\"p\">,</span> </span><span id=\"__span-24-7\"><a id=\"__codelineno-24-7\" name=\"__codelineno-24-7\" href=\"#__codelineno-24-7\"></a><span class=\"w\"> </span><span class=\"nt\">\"slug\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"broken-page\"</span><span class=\"p\">,</span> </span><span id=\"__span-24-8\"><a id=\"__codelineno-24-8\" name=\"__codelineno-24-8\" href=\"#__codelineno-24-8\"></a><span class=\"w\"> </span><span class=\"nt\">\"error\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"Invalid HTML: Unclosed <div> tag\"</span> </span><span id=\"__span-24-9\"><a id=\"__codelineno-24-9\" name=\"__codelineno-24-9\" href=\"#__codelineno-24-9\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-24-10\"><a id=\"__codelineno-24-10\" name=\"__codelineno-24-10\" href=\"#__codelineno-24-10\"></a><span class=\"w\"> </span><span class=\"p\">]</span> </span><span id=\"__span-24-11\"><a id=\"__codelineno-24-11\" name=\"__codelineno-24-11\" href=\"#__codelineno-24-11\"></a><span class=\"p\">}</span> </span></code></pre></div> <p><strong>Response Fields:</strong> - <code>validated</code> (number): Total pages checked - <code>repaired</code> (number): Pages with missing export files (now re-exported) - <code>errors</code> (array): Pages with unfixable errors (invalid HTML, write permissions, etc.)</p> <p><strong>Backend Workflow:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-25-1\"><a id=\"__codelineno-25-1\" name=\"__codelineno-25-1\" href=\"#__codelineno-25-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">publishedPages</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">prisma</span><span class=\"p\">.</span><span class=\"nx\">landingPage</span><span class=\"p\">.</span><span class=\"nx\">findMany</span><span class=\"p\">({</span> </span><span id=\"__span-25-2\"><a id=\"__codelineno-25-2\" name=\"__codelineno-25-2\" href=\"#__codelineno-25-2\"></a><span class=\"w\"> </span><span class=\"nx\">where</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">published</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">true</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">mkdocsSkipExport</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">false</span><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-25-3\"><a id=\"__codelineno-25-3\" name=\"__codelineno-25-3\" href=\"#__codelineno-25-3\"></a><span class=\"p\">});</span> </span><span id=\"__span-25-4\"><a id=\"__codelineno-25-4\" name=\"__codelineno-25-4\" href=\"#__codelineno-25-4\"></a> </span><span id=\"__span-25-5\"><a id=\"__codelineno-25-5\" name=\"__codelineno-25-5\" href=\"#__codelineno-25-5\"></a><span class=\"k\">for</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"w\"> </span><span class=\"k\">of</span><span class=\"w\"> </span><span class=\"nx\">publishedPages</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-25-6\"><a id=\"__codelineno-25-6\" name=\"__codelineno-25-6\" href=\"#__codelineno-25-6\"></a><span class=\"w\"> </span><span class=\"c1\">// 1. Check if export file exists</span> </span><span id=\"__span-25-7\"><a id=\"__codelineno-25-7\" name=\"__codelineno-25-7\" href=\"#__codelineno-25-7\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">exportPath</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"sb\">`mkdocs/docs/overrides/</span><span class=\"si\">${</span><span class=\"nx\">page</span><span class=\"p\">.</span><span class=\"nx\">mkdocsPath</span><span class=\"w\"> </span><span class=\"o\">||</span><span class=\"w\"> </span><span class=\"sb\">`</span><span class=\"si\">${</span><span class=\"nx\">page</span><span class=\"p\">.</span><span class=\"nx\">slug</span><span class=\"si\">}</span><span class=\"sb\">.html`</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">;</span> </span><span id=\"__span-25-8\"><a id=\"__codelineno-25-8\" name=\"__codelineno-25-8\" href=\"#__codelineno-25-8\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">exists</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">fs</span><span class=\"p\">.</span><span class=\"nx\">existsSync</span><span class=\"p\">(</span><span class=\"nx\">exportPath</span><span class=\"p\">);</span> </span><span id=\"__span-25-9\"><a id=\"__codelineno-25-9\" name=\"__codelineno-25-9\" href=\"#__codelineno-25-9\"></a> </span><span id=\"__span-25-10\"><a id=\"__codelineno-25-10\" name=\"__codelineno-25-10\" href=\"#__codelineno-25-10\"></a><span class=\"w\"> </span><span class=\"k\">if</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"o\">!</span><span class=\"nx\">exists</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-25-11\"><a id=\"__codelineno-25-11\" name=\"__codelineno-25-11\" href=\"#__codelineno-25-11\"></a><span class=\"w\"> </span><span class=\"c1\">// 2a. Re-export missing file</span> </span><span id=\"__span-25-12\"><a id=\"__codelineno-25-12\" name=\"__codelineno-25-12\" href=\"#__codelineno-25-12\"></a><span class=\"w\"> </span><span class=\"k\">try</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-25-13\"><a id=\"__codelineno-25-13\" name=\"__codelineno-25-13\" href=\"#__codelineno-25-13\"></a><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">exportPageToMkDocs</span><span class=\"p\">(</span><span class=\"nx\">page</span><span class=\"p\">);</span> </span><span id=\"__span-25-14\"><a id=\"__codelineno-25-14\" name=\"__codelineno-25-14\" href=\"#__codelineno-25-14\"></a><span class=\"w\"> </span><span class=\"nx\">repaired</span><span class=\"o\">++</span><span class=\"p\">;</span> </span><span id=\"__span-25-15\"><a id=\"__codelineno-25-15\" name=\"__codelineno-25-15\" href=\"#__codelineno-25-15\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">catch</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">error</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-25-16\"><a id=\"__codelineno-25-16\" name=\"__codelineno-25-16\" href=\"#__codelineno-25-16\"></a><span class=\"w\"> </span><span class=\"nx\">errors</span><span class=\"p\">.</span><span class=\"nx\">push</span><span class=\"p\">({</span> </span><span id=\"__span-25-17\"><a id=\"__codelineno-25-17\" name=\"__codelineno-25-17\" href=\"#__codelineno-25-17\"></a><span class=\"w\"> </span><span class=\"nx\">pageId</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">page.id</span><span class=\"p\">,</span> </span><span id=\"__span-25-18\"><a id=\"__codelineno-25-18\" name=\"__codelineno-25-18\" href=\"#__codelineno-25-18\"></a><span class=\"w\"> </span><span class=\"nx\">slug</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">page.slug</span><span class=\"p\">,</span> </span><span id=\"__span-25-19\"><a id=\"__codelineno-25-19\" name=\"__codelineno-25-19\" href=\"#__codelineno-25-19\"></a><span class=\"w\"> </span><span class=\"nx\">error</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">error.message</span><span class=\"p\">,</span> </span><span id=\"__span-25-20\"><a id=\"__codelineno-25-20\" name=\"__codelineno-25-20\" href=\"#__codelineno-25-20\"></a><span class=\"w\"> </span><span class=\"p\">});</span> </span><span id=\"__span-25-21\"><a id=\"__codelineno-25-21\" name=\"__codelineno-25-21\" href=\"#__codelineno-25-21\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-25-22\"><a id=\"__codelineno-25-22\" name=\"__codelineno-25-22\" href=\"#__codelineno-25-22\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-25-23\"><a id=\"__codelineno-25-23\" name=\"__codelineno-25-23\" href=\"#__codelineno-25-23\"></a><span class=\"p\">}</span> </span><span id=\"__span-25-24\"><a id=\"__codelineno-25-24\" name=\"__codelineno-25-24\" href=\"#__codelineno-25-24\"></a> </span><span id=\"__span-25-25\"><a id=\"__codelineno-25-25\" name=\"__codelineno-25-25\" href=\"#__codelineno-25-25\"></a><span class=\"k\">return</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">validated</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">publishedPages.length</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">repaired</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">errors</span><span class=\"w\"> </span><span class=\"p\">};</span> </span></code></pre></div> <h3 id=\"build-mkdocs-site-super_admin-only\">Build MkDocs Site (SUPER_ADMIN Only)<a class=\"headerlink\" href=\"#build-mkdocs-site-super_admin-only\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Request:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-26-1\"><a id=\"__codelineno-26-1\" name=\"__codelineno-26-1\" href=\"#__codelineno-26-1\"></a><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">post</span><span class=\"p\">(</span><span class=\"s1\">'/docs/build'</span><span class=\"p\">);</span> </span></code></pre></div> <p><strong>Response (200 OK):</strong></p> <div class=\"language-json highlight\"><pre><span></span><code><span id=\"__span-27-1\"><a id=\"__codelineno-27-1\" name=\"__codelineno-27-1\" href=\"#__codelineno-27-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-27-2\"><a id=\"__codelineno-27-2\" name=\"__codelineno-27-2\" href=\"#__codelineno-27-2\"></a><span class=\"w\"> </span><span class=\"nt\">\"message\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"Site built successfully\"</span><span class=\"p\">,</span> </span><span id=\"__span-27-3\"><a id=\"__codelineno-27-3\" name=\"__codelineno-27-3\" href=\"#__codelineno-27-3\"></a><span class=\"w\"> </span><span class=\"nt\">\"duration\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mf\">12.5</span><span class=\"p\">,</span> </span><span id=\"__span-27-4\"><a id=\"__codelineno-27-4\" name=\"__codelineno-27-4\" href=\"#__codelineno-27-4\"></a><span class=\"w\"> </span><span class=\"nt\">\"pages\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">156</span> </span><span id=\"__span-27-5\"><a id=\"__codelineno-27-5\" name=\"__codelineno-27-5\" href=\"#__codelineno-27-5\"></a><span class=\"p\">}</span> </span></code></pre></div> <p><strong>Response Fields:</strong> - <code>message</code> (string): Success confirmation - <code>duration</code> (number): Build time in seconds - <code>pages</code> (number): Number of pages built</p> <p><strong>Backend Workflow:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-28-1\"><a id=\"__codelineno-28-1\" name=\"__codelineno-28-1\" href=\"#__codelineno-28-1\"></a><span class=\"c1\">// 1. Run mkdocs build command</span> </span><span id=\"__span-28-2\"><a id=\"__codelineno-28-2\" name=\"__codelineno-28-2\" href=\"#__codelineno-28-2\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">startTime</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nb\">Date</span><span class=\"p\">.</span><span class=\"nx\">now</span><span class=\"p\">();</span> </span><span id=\"__span-28-3\"><a id=\"__codelineno-28-3\" name=\"__codelineno-28-3\" href=\"#__codelineno-28-3\"></a><span class=\"nx\">exec</span><span class=\"p\">(</span><span class=\"s1\">'mkdocs build'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">cwd</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'/path/to/mkdocs'</span><span class=\"w\"> </span><span class=\"p\">},</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">error</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">stdout</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">stderr</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-28-4\"><a id=\"__codelineno-28-4\" name=\"__codelineno-28-4\" href=\"#__codelineno-28-4\"></a><span class=\"w\"> </span><span class=\"k\">if</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">error</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-28-5\"><a id=\"__codelineno-28-5\" name=\"__codelineno-28-5\" href=\"#__codelineno-28-5\"></a><span class=\"w\"> </span><span class=\"k\">throw</span><span class=\"w\"> </span><span class=\"ow\">new</span><span class=\"w\"> </span><span class=\"ne\">Error</span><span class=\"p\">(</span><span class=\"sb\">`Build failed: </span><span class=\"si\">${</span><span class=\"nx\">error</span><span class=\"p\">.</span><span class=\"nx\">message</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">);</span> </span><span id=\"__span-28-6\"><a id=\"__codelineno-28-6\" name=\"__codelineno-28-6\" href=\"#__codelineno-28-6\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-28-7\"><a id=\"__codelineno-28-7\" name=\"__codelineno-28-7\" href=\"#__codelineno-28-7\"></a> </span><span id=\"__span-28-8\"><a id=\"__codelineno-28-8\" name=\"__codelineno-28-8\" href=\"#__codelineno-28-8\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">duration</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nb\">Date</span><span class=\"p\">.</span><span class=\"nx\">now</span><span class=\"p\">()</span><span class=\"w\"> </span><span class=\"o\">-</span><span class=\"w\"> </span><span class=\"nx\">startTime</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"o\">/</span><span class=\"w\"> </span><span class=\"mf\">1000</span><span class=\"p\">;</span> </span><span id=\"__span-28-9\"><a id=\"__codelineno-28-9\" name=\"__codelineno-28-9\" href=\"#__codelineno-28-9\"></a> </span><span id=\"__span-28-10\"><a id=\"__codelineno-28-10\" name=\"__codelineno-28-10\" href=\"#__codelineno-28-10\"></a><span class=\"w\"> </span><span class=\"c1\">// 2. Count built pages</span> </span><span id=\"__span-28-11\"><a id=\"__codelineno-28-11\" name=\"__codelineno-28-11\" href=\"#__codelineno-28-11\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">siteDir</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"s1\">'/path/to/mkdocs/site'</span><span class=\"p\">;</span> </span><span id=\"__span-28-12\"><a id=\"__codelineno-28-12\" name=\"__codelineno-28-12\" href=\"#__codelineno-28-12\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">pages</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">countHtmlFiles</span><span class=\"p\">(</span><span class=\"nx\">siteDir</span><span class=\"p\">);</span> </span><span id=\"__span-28-13\"><a id=\"__codelineno-28-13\" name=\"__codelineno-28-13\" href=\"#__codelineno-28-13\"></a> </span><span id=\"__span-28-14\"><a id=\"__codelineno-28-14\" name=\"__codelineno-28-14\" href=\"#__codelineno-28-14\"></a><span class=\"w\"> </span><span class=\"k\">return</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Site built successfully'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">duration</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">pages</span><span class=\"w\"> </span><span class=\"p\">};</span> </span><span id=\"__span-28-15\"><a id=\"__codelineno-28-15\" name=\"__codelineno-28-15\" href=\"#__codelineno-28-15\"></a><span class=\"p\">});</span> </span></code></pre></div> <p><strong>Build Output:</strong></p> <ul> <li><strong>Built site:</strong> <code>mkdocs/site/</code> directory</li> <li><strong>Index:</strong> <code>mkdocs/site/index.html</code></li> <li><strong>Pages:</strong> <code>mkdocs/site/about/index.html</code>, <code>mkdocs/site/contact/index.html</code>, etc.</li> <li><strong>Assets:</strong> <code>mkdocs/site/assets/</code> (CSS, JS, images)</li> </ul> <h2 id=\"code-examples\">Code Examples<a class=\"headerlink\" href=\"#code-examples\" title=\"Permanent link\">\u00b6</a></h2> <h3 id=\"complete-create-page-flow\">Complete Create Page Flow<a class=\"headerlink\" href=\"#complete-create-page-flow\" title=\"Permanent link\">\u00b6</a></h3> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-29-1\"><a id=\"__codelineno-29-1\" name=\"__codelineno-29-1\" href=\"#__codelineno-29-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">handleCreate</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">async</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">values</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">description?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">editorMode?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">EditorMode</span><span class=\"w\"> </span><span class=\"p\">})</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-29-2\"><a id=\"__codelineno-29-2\" name=\"__codelineno-29-2\" href=\"#__codelineno-29-2\"></a><span class=\"w\"> </span><span class=\"k\">try</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-29-3\"><a id=\"__codelineno-29-3\" name=\"__codelineno-29-3\" href=\"#__codelineno-29-3\"></a><span class=\"w\"> </span><span class=\"c1\">// 1. Send create request</span> </span><span id=\"__span-29-4\"><a id=\"__codelineno-29-4\" name=\"__codelineno-29-4\" href=\"#__codelineno-29-4\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">post</span><span class=\"o\"><</span><span class=\"nx\">LandingPage</span><span class=\"o\">></span><span class=\"p\">(</span><span class=\"s1\">'/pages'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">values</span><span class=\"p\">);</span> </span><span id=\"__span-29-5\"><a id=\"__codelineno-29-5\" name=\"__codelineno-29-5\" href=\"#__codelineno-29-5\"></a> </span><span id=\"__span-29-6\"><a id=\"__codelineno-29-6\" name=\"__codelineno-29-6\" href=\"#__codelineno-29-6\"></a><span class=\"w\"> </span><span class=\"c1\">// 2. Show success message</span> </span><span id=\"__span-29-7\"><a id=\"__codelineno-29-7\" name=\"__codelineno-29-7\" href=\"#__codelineno-29-7\"></a><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">success</span><span class=\"p\">(</span><span class=\"s1\">'Page created'</span><span class=\"p\">);</span> </span><span id=\"__span-29-8\"><a id=\"__codelineno-29-8\" name=\"__codelineno-29-8\" href=\"#__codelineno-29-8\"></a> </span><span id=\"__span-29-9\"><a id=\"__codelineno-29-9\" name=\"__codelineno-29-9\" href=\"#__codelineno-29-9\"></a><span class=\"w\"> </span><span class=\"c1\">// 3. Close modal and reset form</span> </span><span id=\"__span-29-10\"><a id=\"__codelineno-29-10\" name=\"__codelineno-29-10\" href=\"#__codelineno-29-10\"></a><span class=\"w\"> </span><span class=\"nx\">setCreateModalOpen</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-29-11\"><a id=\"__codelineno-29-11\" name=\"__codelineno-29-11\" href=\"#__codelineno-29-11\"></a><span class=\"w\"> </span><span class=\"nx\">createForm</span><span class=\"p\">.</span><span class=\"nx\">resetFields</span><span class=\"p\">();</span> </span><span id=\"__span-29-12\"><a id=\"__codelineno-29-12\" name=\"__codelineno-29-12\" href=\"#__codelineno-29-12\"></a> </span><span id=\"__span-29-13\"><a id=\"__codelineno-29-13\" name=\"__codelineno-29-13\" href=\"#__codelineno-29-13\"></a><span class=\"w\"> </span><span class=\"c1\">// 4. Navigate to editor</span> </span><span id=\"__span-29-14\"><a id=\"__codelineno-29-14\" name=\"__codelineno-29-14\" href=\"#__codelineno-29-14\"></a><span class=\"w\"> </span><span class=\"nx\">navigate</span><span class=\"p\">(</span><span class=\"sb\">`/app/pages/</span><span class=\"si\">${</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">id</span><span class=\"si\">}</span><span class=\"sb\">/edit`</span><span class=\"p\">);</span> </span><span id=\"__span-29-15\"><a id=\"__codelineno-29-15\" name=\"__codelineno-29-15\" href=\"#__codelineno-29-15\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">catch</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">err</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">unknown</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-29-16\"><a id=\"__codelineno-29-16\" name=\"__codelineno-29-16\" href=\"#__codelineno-29-16\"></a><span class=\"w\"> </span><span class=\"c1\">// 5. Extract specific error message from API response</span> </span><span id=\"__span-29-17\"><a id=\"__codelineno-29-17\" name=\"__codelineno-29-17\" href=\"#__codelineno-29-17\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">msg</span><span class=\"w\"> </span><span class=\"o\">=</span> </span><span id=\"__span-29-18\"><a id=\"__codelineno-29-18\" name=\"__codelineno-29-18\" href=\"#__codelineno-29-18\"></a><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">err</span><span class=\"w\"> </span><span class=\"kr\">as</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">response</span><span class=\"o\">?:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"o\">?:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">error</span><span class=\"o\">?:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">message?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"p\">})</span> </span><span id=\"__span-29-19\"><a id=\"__codelineno-29-19\" name=\"__codelineno-29-19\" href=\"#__codelineno-29-19\"></a><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"p\">.</span><span class=\"nx\">response</span><span class=\"o\">?</span><span class=\"p\">.</span><span class=\"nx\">data</span><span class=\"o\">?</span><span class=\"p\">.</span><span class=\"nx\">error</span><span class=\"o\">?</span><span class=\"p\">.</span><span class=\"nx\">message</span><span class=\"w\"> </span><span class=\"o\">||</span><span class=\"w\"> </span><span class=\"s1\">'Failed to create page'</span><span class=\"p\">;</span> </span><span id=\"__span-29-20\"><a id=\"__codelineno-29-20\" name=\"__codelineno-29-20\" href=\"#__codelineno-29-20\"></a> </span><span id=\"__span-29-21\"><a id=\"__codelineno-29-21\" name=\"__codelineno-29-21\" href=\"#__codelineno-29-21\"></a><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">error</span><span class=\"p\">(</span><span class=\"nx\">msg</span><span class=\"p\">);</span> </span><span id=\"__span-29-22\"><a id=\"__codelineno-29-22\" name=\"__codelineno-29-22\" href=\"#__codelineno-29-22\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-29-23\"><a id=\"__codelineno-29-23\" name=\"__codelineno-29-23\" href=\"#__codelineno-29-23\"></a><span class=\"p\">};</span> </span></code></pre></div> <p><strong>Key Steps:</strong> 1. Send POST request with form values 2. Show success message 3. Close modal and reset form (cleanup) 4. Navigate to editor page (user can start editing immediately) 5. Extract specific error message from API response (show useful feedback)</p> <h3 id=\"sync-overrides-flow\">Sync Overrides Flow<a class=\"headerlink\" href=\"#sync-overrides-flow\" title=\"Permanent link\">\u00b6</a></h3> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-30-1\"><a id=\"__codelineno-30-1\" name=\"__codelineno-30-1\" href=\"#__codelineno-30-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">handleSyncOverrides</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">async</span><span class=\"w\"> </span><span class=\"p\">()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-30-2\"><a id=\"__codelineno-30-2\" name=\"__codelineno-30-2\" href=\"#__codelineno-30-2\"></a><span class=\"w\"> </span><span class=\"nx\">setSyncing</span><span class=\"p\">(</span><span class=\"kc\">true</span><span class=\"p\">);</span> </span><span id=\"__span-30-3\"><a id=\"__codelineno-30-3\" name=\"__codelineno-30-3\" href=\"#__codelineno-30-3\"></a><span class=\"w\"> </span><span class=\"k\">try</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-30-4\"><a id=\"__codelineno-30-4\" name=\"__codelineno-30-4\" href=\"#__codelineno-30-4\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">post</span><span class=\"o\"><</span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">imported</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">updated</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">stubs</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"o\">></span><span class=\"p\">(</span><span class=\"s1\">'/pages/sync'</span><span class=\"p\">);</span> </span><span id=\"__span-30-5\"><a id=\"__codelineno-30-5\" name=\"__codelineno-30-5\" href=\"#__codelineno-30-5\"></a> </span><span id=\"__span-30-6\"><a id=\"__codelineno-30-6\" name=\"__codelineno-30-6\" href=\"#__codelineno-30-6\"></a><span class=\"w\"> </span><span class=\"c1\">// Show different messages based on results</span> </span><span id=\"__span-30-7\"><a id=\"__codelineno-30-7\" name=\"__codelineno-30-7\" href=\"#__codelineno-30-7\"></a><span class=\"w\"> </span><span class=\"k\">if</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">imported</span><span class=\"w\"> </span><span class=\"o\">></span><span class=\"w\"> </span><span class=\"mf\">0</span><span class=\"w\"> </span><span class=\"o\">||</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">updated</span><span class=\"w\"> </span><span class=\"o\">></span><span class=\"w\"> </span><span class=\"mf\">0</span><span class=\"w\"> </span><span class=\"o\">||</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">stubs</span><span class=\"w\"> </span><span class=\"o\">></span><span class=\"w\"> </span><span class=\"mf\">0</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-30-8\"><a id=\"__codelineno-30-8\" name=\"__codelineno-30-8\" href=\"#__codelineno-30-8\"></a><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">success</span><span class=\"p\">(</span> </span><span id=\"__span-30-9\"><a id=\"__codelineno-30-9\" name=\"__codelineno-30-9\" href=\"#__codelineno-30-9\"></a><span class=\"w\"> </span><span class=\"sb\">`Synced: </span><span class=\"si\">${</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">imported</span><span class=\"si\">}</span><span class=\"sb\"> imported, </span><span class=\"si\">${</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">updated</span><span class=\"si\">}</span><span class=\"sb\"> updated, </span><span class=\"si\">${</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">stubs</span><span class=\"si\">}</span><span class=\"sb\"> stubs created`</span> </span><span id=\"__span-30-10\"><a id=\"__codelineno-30-10\" name=\"__codelineno-30-10\" href=\"#__codelineno-30-10\"></a><span class=\"w\"> </span><span class=\"p\">);</span> </span><span id=\"__span-30-11\"><a id=\"__codelineno-30-11\" name=\"__codelineno-30-11\" href=\"#__codelineno-30-11\"></a><span class=\"w\"> </span><span class=\"nx\">fetchPages</span><span class=\"p\">();</span><span class=\"w\"> </span><span class=\"c1\">// Refresh table to show new/updated pages</span> </span><span id=\"__span-30-12\"><a id=\"__codelineno-30-12\" name=\"__codelineno-30-12\" href=\"#__codelineno-30-12\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">else</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-30-13\"><a id=\"__codelineno-30-13\" name=\"__codelineno-30-13\" href=\"#__codelineno-30-13\"></a><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">info</span><span class=\"p\">(</span><span class=\"s1\">'No new overrides to sync'</span><span class=\"p\">);</span> </span><span id=\"__span-30-14\"><a id=\"__codelineno-30-14\" name=\"__codelineno-30-14\" href=\"#__codelineno-30-14\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-30-15\"><a id=\"__codelineno-30-15\" name=\"__codelineno-30-15\" href=\"#__codelineno-30-15\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">catch</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-30-16\"><a id=\"__codelineno-30-16\" name=\"__codelineno-30-16\" href=\"#__codelineno-30-16\"></a><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">error</span><span class=\"p\">(</span><span class=\"s1\">'Failed to sync overrides'</span><span class=\"p\">);</span> </span><span id=\"__span-30-17\"><a id=\"__codelineno-30-17\" name=\"__codelineno-30-17\" href=\"#__codelineno-30-17\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">finally</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-30-18\"><a id=\"__codelineno-30-18\" name=\"__codelineno-30-18\" href=\"#__codelineno-30-18\"></a><span class=\"w\"> </span><span class=\"nx\">setSyncing</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-30-19\"><a id=\"__codelineno-30-19\" name=\"__codelineno-30-19\" href=\"#__codelineno-30-19\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-30-20\"><a id=\"__codelineno-30-20\" name=\"__codelineno-30-20\" href=\"#__codelineno-30-20\"></a><span class=\"p\">};</span> </span></code></pre></div> <p><strong>Key Features:</strong> - <strong>Conditional message:</strong> Different message if sync found changes vs. no changes - <strong>Detailed counts:</strong> Shows imported, updated, and stubs created - <strong>Table refresh:</strong> Fetches pages again to show newly imported pages</p> <h3 id=\"validate-exports-flow\">Validate Exports Flow<a class=\"headerlink\" href=\"#validate-exports-flow\" title=\"Permanent link\">\u00b6</a></h3> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-31-1\"><a id=\"__codelineno-31-1\" name=\"__codelineno-31-1\" href=\"#__codelineno-31-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">handleValidateExports</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">async</span><span class=\"w\"> </span><span class=\"p\">()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-31-2\"><a id=\"__codelineno-31-2\" name=\"__codelineno-31-2\" href=\"#__codelineno-31-2\"></a><span class=\"w\"> </span><span class=\"nx\">setValidating</span><span class=\"p\">(</span><span class=\"kc\">true</span><span class=\"p\">);</span> </span><span id=\"__span-31-3\"><a id=\"__codelineno-31-3\" name=\"__codelineno-31-3\" href=\"#__codelineno-31-3\"></a><span class=\"w\"> </span><span class=\"k\">try</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-31-4\"><a id=\"__codelineno-31-4\" name=\"__codelineno-31-4\" href=\"#__codelineno-31-4\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">post</span><span class=\"o\"><</span><span class=\"p\">{</span> </span><span id=\"__span-31-5\"><a id=\"__codelineno-31-5\" name=\"__codelineno-31-5\" href=\"#__codelineno-31-5\"></a><span class=\"w\"> </span><span class=\"nx\">validated</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"p\">;</span> </span><span id=\"__span-31-6\"><a id=\"__codelineno-31-6\" name=\"__codelineno-31-6\" href=\"#__codelineno-31-6\"></a><span class=\"w\"> </span><span class=\"nx\">repaired</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"p\">;</span> </span><span id=\"__span-31-7\"><a id=\"__codelineno-31-7\" name=\"__codelineno-31-7\" href=\"#__codelineno-31-7\"></a><span class=\"w\"> </span><span class=\"nx\">errors</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">Array</span><span class=\"o\"><</span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">pageId</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">slug</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">error</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"o\">></span><span class=\"p\">;</span> </span><span id=\"__span-31-8\"><a id=\"__codelineno-31-8\" name=\"__codelineno-31-8\" href=\"#__codelineno-31-8\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"o\">></span><span class=\"p\">(</span><span class=\"s1\">'/pages/validate'</span><span class=\"p\">);</span> </span><span id=\"__span-31-9\"><a id=\"__codelineno-31-9\" name=\"__codelineno-31-9\" href=\"#__codelineno-31-9\"></a> </span><span id=\"__span-31-10\"><a id=\"__codelineno-31-10\" name=\"__codelineno-31-10\" href=\"#__codelineno-31-10\"></a><span class=\"w\"> </span><span class=\"c1\">// Show appropriate message based on results</span> </span><span id=\"__span-31-11\"><a id=\"__codelineno-31-11\" name=\"__codelineno-31-11\" href=\"#__codelineno-31-11\"></a><span class=\"w\"> </span><span class=\"k\">if</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">repaired</span><span class=\"w\"> </span><span class=\"o\">></span><span class=\"w\"> </span><span class=\"mf\">0</span><span class=\"w\"> </span><span class=\"o\">||</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">errors</span><span class=\"p\">.</span><span class=\"nx\">length</span><span class=\"w\"> </span><span class=\"o\">></span><span class=\"w\"> </span><span class=\"mf\">0</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-31-12\"><a id=\"__codelineno-31-12\" name=\"__codelineno-31-12\" href=\"#__codelineno-31-12\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">msg</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"sb\">`Validated </span><span class=\"si\">${</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">validated</span><span class=\"si\">}</span><span class=\"sb\"> pages: </span><span class=\"si\">${</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">repaired</span><span class=\"si\">}</span><span class=\"sb\"> repaired`</span><span class=\"p\">;</span> </span><span id=\"__span-31-13\"><a id=\"__codelineno-31-13\" name=\"__codelineno-31-13\" href=\"#__codelineno-31-13\"></a><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">errors</span><span class=\"p\">.</span><span class=\"nx\">length</span><span class=\"w\"> </span><span class=\"o\">></span><span class=\"w\"> </span><span class=\"mf\">0</span> </span><span id=\"__span-31-14\"><a id=\"__codelineno-31-14\" name=\"__codelineno-31-14\" href=\"#__codelineno-31-14\"></a><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">warning</span><span class=\"p\">(</span><span class=\"sb\">`</span><span class=\"si\">${</span><span class=\"nx\">msg</span><span class=\"si\">}</span><span class=\"sb\">, </span><span class=\"si\">${</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">errors</span><span class=\"p\">.</span><span class=\"nx\">length</span><span class=\"si\">}</span><span class=\"sb\"> errors`</span><span class=\"p\">)</span> </span><span id=\"__span-31-15\"><a id=\"__codelineno-31-15\" name=\"__codelineno-31-15\" href=\"#__codelineno-31-15\"></a><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">success</span><span class=\"p\">(</span><span class=\"nx\">msg</span><span class=\"p\">);</span> </span><span id=\"__span-31-16\"><a id=\"__codelineno-31-16\" name=\"__codelineno-31-16\" href=\"#__codelineno-31-16\"></a><span class=\"w\"> </span><span class=\"nx\">fetchPages</span><span class=\"p\">();</span><span class=\"w\"> </span><span class=\"c1\">// Refresh table</span> </span><span id=\"__span-31-17\"><a id=\"__codelineno-31-17\" name=\"__codelineno-31-17\" href=\"#__codelineno-31-17\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">else</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-31-18\"><a id=\"__codelineno-31-18\" name=\"__codelineno-31-18\" href=\"#__codelineno-31-18\"></a><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">info</span><span class=\"p\">(</span><span class=\"sb\">`Validated </span><span class=\"si\">${</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">validated</span><span class=\"si\">}</span><span class=\"sb\"> pages - all OK`</span><span class=\"p\">);</span> </span><span id=\"__span-31-19\"><a id=\"__codelineno-31-19\" name=\"__codelineno-31-19\" href=\"#__codelineno-31-19\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-31-20\"><a id=\"__codelineno-31-20\" name=\"__codelineno-31-20\" href=\"#__codelineno-31-20\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">catch</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-31-21\"><a id=\"__codelineno-31-21\" name=\"__codelineno-31-21\" href=\"#__codelineno-31-21\"></a><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">error</span><span class=\"p\">(</span><span class=\"s1\">'Failed to validate exports'</span><span class=\"p\">);</span> </span><span id=\"__span-31-22\"><a id=\"__codelineno-31-22\" name=\"__codelineno-31-22\" href=\"#__codelineno-31-22\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">finally</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-31-23\"><a id=\"__codelineno-31-23\" name=\"__codelineno-31-23\" href=\"#__codelineno-31-23\"></a><span class=\"w\"> </span><span class=\"nx\">setValidating</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-31-24\"><a id=\"__codelineno-31-24\" name=\"__codelineno-31-24\" href=\"#__codelineno-31-24\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-31-25\"><a id=\"__codelineno-31-25\" name=\"__codelineno-31-25\" href=\"#__codelineno-31-25\"></a><span class=\"p\">};</span> </span></code></pre></div> <p><strong>Key Features:</strong> - <strong>Conditional message type:</strong> Success (repaired only), warning (repaired + errors), info (all OK) - <strong>Error count:</strong> Shows number of errors found - <strong>Table refresh:</strong> Only refreshes if repairs were made</p> <h3 id=\"toggle-published-status_1\">Toggle Published Status<a class=\"headerlink\" href=\"#toggle-published-status_1\" title=\"Permanent link\">\u00b6</a></h3> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-32-1\"><a id=\"__codelineno-32-1\" name=\"__codelineno-32-1\" href=\"#__codelineno-32-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">handleTogglePublished</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">async</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">page</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">LandingPage</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-32-2\"><a id=\"__codelineno-32-2\" name=\"__codelineno-32-2\" href=\"#__codelineno-32-2\"></a><span class=\"w\"> </span><span class=\"k\">try</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-32-3\"><a id=\"__codelineno-32-3\" name=\"__codelineno-32-3\" href=\"#__codelineno-32-3\"></a><span class=\"w\"> </span><span class=\"c1\">// 1. Toggle published status</span> </span><span id=\"__span-32-4\"><a id=\"__codelineno-32-4\" name=\"__codelineno-32-4\" href=\"#__codelineno-32-4\"></a><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">put</span><span class=\"p\">(</span><span class=\"sb\">`/pages/</span><span class=\"si\">${</span><span class=\"nx\">page</span><span class=\"p\">.</span><span class=\"nx\">id</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-32-5\"><a id=\"__codelineno-32-5\" name=\"__codelineno-32-5\" href=\"#__codelineno-32-5\"></a><span class=\"w\"> </span><span class=\"nx\">published</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"o\">!</span><span class=\"nx\">page</span><span class=\"p\">.</span><span class=\"nx\">published</span><span class=\"p\">,</span> </span><span id=\"__span-32-6\"><a id=\"__codelineno-32-6\" name=\"__codelineno-32-6\" href=\"#__codelineno-32-6\"></a><span class=\"w\"> </span><span class=\"p\">});</span> </span><span id=\"__span-32-7\"><a id=\"__codelineno-32-7\" name=\"__codelineno-32-7\" href=\"#__codelineno-32-7\"></a> </span><span id=\"__span-32-8\"><a id=\"__codelineno-32-8\" name=\"__codelineno-32-8\" href=\"#__codelineno-32-8\"></a><span class=\"w\"> </span><span class=\"c1\">// 2. Show success message</span> </span><span id=\"__span-32-9\"><a id=\"__codelineno-32-9\" name=\"__codelineno-32-9\" href=\"#__codelineno-32-9\"></a><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">success</span><span class=\"p\">(</span><span class=\"nx\">page</span><span class=\"p\">.</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'Page unpublished'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Page published'</span><span class=\"p\">);</span> </span><span id=\"__span-32-10\"><a id=\"__codelineno-32-10\" name=\"__codelineno-32-10\" href=\"#__codelineno-32-10\"></a> </span><span id=\"__span-32-11\"><a id=\"__codelineno-32-11\" name=\"__codelineno-32-11\" href=\"#__codelineno-32-11\"></a><span class=\"w\"> </span><span class=\"c1\">// 3. Refresh table</span> </span><span id=\"__span-32-12\"><a id=\"__codelineno-32-12\" name=\"__codelineno-32-12\" href=\"#__codelineno-32-12\"></a><span class=\"w\"> </span><span class=\"nx\">fetchPages</span><span class=\"p\">();</span> </span><span id=\"__span-32-13\"><a id=\"__codelineno-32-13\" name=\"__codelineno-32-13\" href=\"#__codelineno-32-13\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">catch</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-32-14\"><a id=\"__codelineno-32-14\" name=\"__codelineno-32-14\" href=\"#__codelineno-32-14\"></a><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">error</span><span class=\"p\">(</span><span class=\"s1\">'Failed to update page'</span><span class=\"p\">);</span> </span><span id=\"__span-32-15\"><a id=\"__codelineno-32-15\" name=\"__codelineno-32-15\" href=\"#__codelineno-32-15\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-32-16\"><a id=\"__codelineno-32-16\" name=\"__codelineno-32-16\" href=\"#__codelineno-32-16\"></a><span class=\"p\">};</span> </span></code></pre></div> <p><strong>Key Features:</strong> - <strong>Conditional message:</strong> \"Page published\" vs. \"Page unpublished\" - <strong>No confirmation:</strong> Immediate toggle (can be toggled back easily) - <strong>Table refresh:</strong> Shows updated status tag</p> <h2 id=\"performance-considerations\">Performance Considerations<a class=\"headerlink\" href=\"#performance-considerations\" title=\"Permanent link\">\u00b6</a></h2> <h3 id=\"debounced-search-300ms\">Debounced Search (300ms)<a class=\"headerlink\" href=\"#debounced-search-300ms\" title=\"Permanent link\">\u00b6</a></h3> <p>Search queries API after 300ms delay:</p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-33-1\"><a id=\"__codelineno-33-1\" name=\"__codelineno-33-1\" href=\"#__codelineno-33-1\"></a><span class=\"nx\">searchTimerRef</span><span class=\"p\">.</span><span class=\"nx\">current</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">setTimeout</span><span class=\"p\">(()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">setDebouncedSearch</span><span class=\"p\">(</span><span class=\"nx\">value</span><span class=\"p\">),</span><span class=\"w\"> </span><span class=\"mf\">300</span><span class=\"p\">);</span> </span></code></pre></div> <p><strong>Performance Impact:</strong> - Without debounce: Typing \"campaign\" (8 chars) = 8 API calls - With 300ms debounce: Typing \"campaign\" = 1 API call - <strong>88% reduction in API calls</strong></p> <h3 id=\"server-side-pagination\">Server-Side Pagination<a class=\"headerlink\" href=\"#server-side-pagination\" title=\"Permanent link\">\u00b6</a></h3> <p>Table uses server-side pagination to handle large page counts:</p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-34-1\"><a id=\"__codelineno-34-1\" name=\"__codelineno-34-1\" href=\"#__codelineno-34-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">get</span><span class=\"p\">(</span><span class=\"s1\">'/pages'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-34-2\"><a id=\"__codelineno-34-2\" name=\"__codelineno-34-2\" href=\"#__codelineno-34-2\"></a><span class=\"w\"> </span><span class=\"nx\">params</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-34-3\"><a id=\"__codelineno-34-3\" name=\"__codelineno-34-3\" href=\"#__codelineno-34-3\"></a><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">pagination.current</span><span class=\"p\">,</span> </span><span id=\"__span-34-4\"><a id=\"__codelineno-34-4\" name=\"__codelineno-34-4\" href=\"#__codelineno-34-4\"></a><span class=\"w\"> </span><span class=\"nx\">limit</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">pagination.pageSize</span><span class=\"p\">,</span> </span><span id=\"__span-34-5\"><a id=\"__codelineno-34-5\" name=\"__codelineno-34-5\" href=\"#__codelineno-34-5\"></a><span class=\"w\"> </span><span class=\"nx\">search</span><span class=\"p\">,</span> </span><span id=\"__span-34-6\"><a id=\"__codelineno-34-6\" name=\"__codelineno-34-6\" href=\"#__codelineno-34-6\"></a><span class=\"w\"> </span><span class=\"nx\">published</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">publishedFilter</span><span class=\"p\">,</span> </span><span id=\"__span-34-7\"><a id=\"__codelineno-34-7\" name=\"__codelineno-34-7\" href=\"#__codelineno-34-7\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-34-8\"><a id=\"__codelineno-34-8\" name=\"__codelineno-34-8\" href=\"#__codelineno-34-8\"></a><span class=\"p\">});</span> </span></code></pre></div> <p><strong>Scalability:</strong> - Works efficiently with 10 to 1,000+ pages - Only fetches current page (20-100 items) - Backend applies filters before pagination</p> <h3 id=\"conditional-settings-form-fields_1\">Conditional Settings Form Fields<a class=\"headerlink\" href=\"#conditional-settings-form-fields_1\" title=\"Permanent link\">\u00b6</a></h3> <p>Settings modal uses <code>shouldUpdate</code> to avoid rendering hidden fields:</p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-35-1\"><a id=\"__codelineno-35-1\" name=\"__codelineno-35-1\" href=\"#__codelineno-35-1\"></a><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">noStyle</span><span class=\"w\"> </span><span class=\"nx\">shouldUpdate</span><span class=\"o\">=</span><span class=\"p\">{(</span><span class=\"nx\">prev</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">cur</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">prev</span><span class=\"p\">.</span><span class=\"nx\">mkdocsSkipExport</span><span class=\"w\"> </span><span class=\"o\">!==</span><span class=\"w\"> </span><span class=\"nx\">cur</span><span class=\"p\">.</span><span class=\"nx\">mkdocsSkipExport</span><span class=\"p\">}</span><span class=\"o\">></span> </span><span id=\"__span-35-2\"><a id=\"__codelineno-35-2\" name=\"__codelineno-35-2\" href=\"#__codelineno-35-2\"></a><span class=\"w\"> </span><span class=\"p\">{({</span><span class=\"w\"> </span><span class=\"nx\">getFieldValue</span><span class=\"w\"> </span><span class=\"p\">})</span><span class=\"w\"> </span><span class=\"p\">=></span> </span><span id=\"__span-35-3\"><a id=\"__codelineno-35-3\" name=\"__codelineno-35-3\" href=\"#__codelineno-35-3\"></a><span class=\"w\"> </span><span class=\"o\">!</span><span class=\"nx\">getFieldValue</span><span class=\"p\">(</span><span class=\"s1\">'mkdocsSkipExport'</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"o\">&&</span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-35-4\"><a id=\"__codelineno-35-4\" name=\"__codelineno-35-4\" href=\"#__codelineno-35-4\"></a><span class=\"w\"> </span><span class=\"o\"><></span> </span><span id=\"__span-35-5\"><a id=\"__codelineno-35-5\" name=\"__codelineno-35-5\" href=\"#__codelineno-35-5\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"cm\">/* MkDocs fields only rendered if checkbox NOT checked */</span><span class=\"p\">}</span> </span><span id=\"__span-35-6\"><a id=\"__codelineno-35-6\" name=\"__codelineno-35-6\" href=\"#__codelineno-35-6\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/></span> </span><span id=\"__span-35-7\"><a id=\"__codelineno-35-7\" name=\"__codelineno-35-7\" href=\"#__codelineno-35-7\"></a><span class=\"w\"> </span><span class=\"p\">)</span> </span><span id=\"__span-35-8\"><a id=\"__codelineno-35-8\" name=\"__codelineno-35-8\" href=\"#__codelineno-35-8\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-35-9\"><a id=\"__codelineno-35-9\" name=\"__codelineno-35-9\" href=\"#__codelineno-35-9\"></a><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span></code></pre></div> <p><strong>Benefits:</strong> - <strong>Faster rendering:</strong> Hidden fields not added to DOM - <strong>Smaller bundle:</strong> Conditional renders reduce component tree size - <strong>Better UX:</strong> Form dynamically adapts to user selections</p> <h2 id=\"responsive-design\">Responsive Design<a class=\"headerlink\" href=\"#responsive-design\" title=\"Permanent link\">\u00b6</a></h2> <h3 id=\"mobile-table-layout\">Mobile Table Layout<a class=\"headerlink\" href=\"#mobile-table-layout\" title=\"Permanent link\">\u00b6</a></h3> <p>Table adapts to mobile viewports by hiding less important columns:</p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-36-1\"><a id=\"__codelineno-36-1\" name=\"__codelineno-36-1\" href=\"#__codelineno-36-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-36-2\"><a id=\"__codelineno-36-2\" name=\"__codelineno-36-2\" href=\"#__codelineno-36-2\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Editor'</span><span class=\"p\">,</span> </span><span id=\"__span-36-3\"><a id=\"__codelineno-36-3\" name=\"__codelineno-36-3\" href=\"#__codelineno-36-3\"></a><span class=\"w\"> </span><span class=\"nx\">dataIndex</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'editorMode'</span><span class=\"p\">,</span> </span><span id=\"__span-36-4\"><a id=\"__codelineno-36-4\" name=\"__codelineno-36-4\" href=\"#__codelineno-36-4\"></a><span class=\"w\"> </span><span class=\"nx\">responsive</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"s1\">'sm'</span><span class=\"p\">],</span><span class=\"w\"> </span><span class=\"c1\">// Hidden on mobile (xs)</span> </span><span id=\"__span-36-5\"><a id=\"__codelineno-36-5\" name=\"__codelineno-36-5\" href=\"#__codelineno-36-5\"></a><span class=\"p\">},</span> </span><span id=\"__span-36-6\"><a id=\"__codelineno-36-6\" name=\"__codelineno-36-6\" href=\"#__codelineno-36-6\"></a><span class=\"p\">{</span> </span><span id=\"__span-36-7\"><a id=\"__codelineno-36-7\" name=\"__codelineno-36-7\" href=\"#__codelineno-36-7\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'MkDocs'</span><span class=\"p\">,</span> </span><span id=\"__span-36-8\"><a id=\"__codelineno-36-8\" name=\"__codelineno-36-8\" href=\"#__codelineno-36-8\"></a><span class=\"w\"> </span><span class=\"nx\">dataIndex</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'mkdocsPath'</span><span class=\"p\">,</span> </span><span id=\"__span-36-9\"><a id=\"__codelineno-36-9\" name=\"__codelineno-36-9\" href=\"#__codelineno-36-9\"></a><span class=\"w\"> </span><span class=\"nx\">responsive</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"s1\">'lg'</span><span class=\"p\">],</span><span class=\"w\"> </span><span class=\"c1\">// Hidden on mobile/tablet (xs, sm, md)</span> </span><span id=\"__span-36-10\"><a id=\"__codelineno-36-10\" name=\"__codelineno-36-10\" href=\"#__codelineno-36-10\"></a><span class=\"p\">},</span> </span><span id=\"__span-36-11\"><a id=\"__codelineno-36-11\" name=\"__codelineno-36-11\" href=\"#__codelineno-36-11\"></a><span class=\"p\">{</span> </span><span id=\"__span-36-12\"><a id=\"__codelineno-36-12\" name=\"__codelineno-36-12\" href=\"#__codelineno-36-12\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Created'</span><span class=\"p\">,</span> </span><span id=\"__span-36-13\"><a id=\"__codelineno-36-13\" name=\"__codelineno-36-13\" href=\"#__codelineno-36-13\"></a><span class=\"w\"> </span><span class=\"nx\">dataIndex</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'createdAt'</span><span class=\"p\">,</span> </span><span id=\"__span-36-14\"><a id=\"__codelineno-36-14\" name=\"__codelineno-36-14\" href=\"#__codelineno-36-14\"></a><span class=\"w\"> </span><span class=\"nx\">responsive</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"s1\">'md'</span><span class=\"p\">],</span><span class=\"w\"> </span><span class=\"c1\">// Hidden on mobile (xs, sm)</span> </span><span id=\"__span-36-15\"><a id=\"__codelineno-36-15\" name=\"__codelineno-36-15\" href=\"#__codelineno-36-15\"></a><span class=\"p\">},</span> </span></code></pre></div> <p><strong>Mobile Columns (xs):</strong> - Title (with /p/:slug below) - Status - Actions</p> <p><strong>Tablet Columns (sm, md):</strong> - Title + Editor + Status + Created + Updated + Actions</p> <p><strong>Desktop Columns (lg+):</strong> - All columns including MkDocs</p> <h3 id=\"action-button-wrapping\">Action Button Wrapping<a class=\"headerlink\" href=\"#action-button-wrapping\" title=\"Permanent link\">\u00b6</a></h3> <p>Action buttons wrap on narrow viewports:</p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-37-1\"><a id=\"__codelineno-37-1\" name=\"__codelineno-37-1\" href=\"#__codelineno-37-1\"></a><span class=\"o\"><</span><span class=\"nx\">Space</span><span class=\"o\">></span> </span><span id=\"__span-37-2\"><a id=\"__codelineno-37-2\" name=\"__codelineno-37-2\" href=\"#__codelineno-37-2\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Button</span><span class=\"w\"> </span><span class=\"nx\">icon</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"o\"><</span><span class=\"nx\">EditOutlined</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-37-3\"><a id=\"__codelineno-37-3\" name=\"__codelineno-37-3\" href=\"#__codelineno-37-3\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Button</span><span class=\"w\"> </span><span class=\"nx\">icon</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"o\"><</span><span class=\"nx\">SettingOutlined</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-37-4\"><a id=\"__codelineno-37-4\" name=\"__codelineno-37-4\" href=\"#__codelineno-37-4\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">&&</span><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Button</span><span class=\"w\"> </span><span class=\"nx\">icon</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"o\"><</span><span class=\"nx\">EyeOutlined</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span> </span><span id=\"__span-37-5\"><a id=\"__codelineno-37-5\" name=\"__codelineno-37-5\" href=\"#__codelineno-37-5\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Button</span><span class=\"o\">></span><span class=\"p\">{</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'Unpublish'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Publish'</span><span class=\"p\">}</span><span class=\"o\"><</span><span class=\"err\">/Button></span> </span><span id=\"__span-37-6\"><a id=\"__codelineno-37-6\" name=\"__codelineno-37-6\" href=\"#__codelineno-37-6\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Popconfirm</span><span class=\"o\">><</span><span class=\"nx\">Button</span><span class=\"w\"> </span><span class=\"nx\">icon</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"o\"><</span><span class=\"nx\">DeleteOutlined</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">/><</span><span class=\"err\">/Popconfirm></span> </span><span id=\"__span-37-7\"><a id=\"__codelineno-37-7\" name=\"__codelineno-37-7\" href=\"#__codelineno-37-7\"></a><span class=\"o\"><</span><span class=\"err\">/Space></span> </span></code></pre></div> <p><strong>Space Component:</strong> - Automatically wraps buttons when width insufficient - Maintains consistent spacing (8px gap) - No horizontal scrolling on mobile</p> <h2 id=\"accessibility\">Accessibility<a class=\"headerlink\" href=\"#accessibility\" title=\"Permanent link\">\u00b6</a></h2> <h3 id=\"keyboard-navigation\">Keyboard Navigation<a class=\"headerlink\" href=\"#keyboard-navigation\" title=\"Permanent link\">\u00b6</a></h3> <p>All interactive elements are keyboard-accessible:</p> <p><strong>Table Navigation:</strong> - <strong>Tab:</strong> Move between action buttons (Edit \u2192 Settings \u2192 View \u2192 Publish \u2192 Delete) - <strong>Enter/Space:</strong> Activate focused button - <strong>Arrow Keys:</strong> Navigate table rows (Ant Design built-in)</p> <p><strong>Modal Forms:</strong> - <strong>Tab:</strong> Move between form fields (Title \u2192 Description \u2192 Editor Mode) - <strong>Enter:</strong> Submit form (same as clicking OK button) - <strong>Escape:</strong> Close modal</p> <h3 id=\"screen-reader-support\">Screen Reader Support<a class=\"headerlink\" href=\"#screen-reader-support\" title=\"Permanent link\">\u00b6</a></h3> <p>All elements have proper ARIA labels:</p> <p><strong>Action Buttons:</strong> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-38-1\"><a id=\"__codelineno-38-1\" name=\"__codelineno-38-1\" href=\"#__codelineno-38-1\"></a><span class=\"o\"><</span><span class=\"nx\">Button</span> </span><span id=\"__span-38-2\"><a id=\"__codelineno-38-2\" name=\"__codelineno-38-2\" href=\"#__codelineno-38-2\"></a><span class=\"w\"> </span><span class=\"nx\">icon</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"o\"><</span><span class=\"nx\">EditOutlined</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span> </span><span id=\"__span-38-3\"><a id=\"__codelineno-38-3\" name=\"__codelineno-38-3\" href=\"#__codelineno-38-3\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">=</span><span class=\"s2\">\"Edit in builder\"</span> </span><span id=\"__span-38-4\"><a id=\"__codelineno-38-4\" name=\"__codelineno-38-4\" href=\"#__codelineno-38-4\"></a><span class=\"w\"> </span><span class=\"nx\">aria</span><span class=\"o\">-</span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"sb\">`Edit page </span><span class=\"si\">${</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">title</span><span class=\"si\">}</span><span class=\"sb\"> in </span><span class=\"si\">${</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">editorMode</span><span class=\"w\"> </span><span class=\"o\">===</span><span class=\"w\"> </span><span class=\"s1\">'CODE'</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'code editor'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'visual builder'</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">}</span> </span><span id=\"__span-38-5\"><a id=\"__codelineno-38-5\" name=\"__codelineno-38-5\" href=\"#__codelineno-38-5\"></a><span class=\"err\">/></span> </span></code></pre></div></p> <p><strong>Status Tags:</strong> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-39-1\"><a id=\"__codelineno-39-1\" name=\"__codelineno-39-1\" href=\"#__codelineno-39-1\"></a><span class=\"o\"><</span><span class=\"nx\">Tag</span> </span><span id=\"__span-39-2\"><a id=\"__codelineno-39-2\" name=\"__codelineno-39-2\" href=\"#__codelineno-39-2\"></a><span class=\"w\"> </span><span class=\"nx\">color</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'green'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'default'</span><span class=\"p\">}</span> </span><span id=\"__span-39-3\"><a id=\"__codelineno-39-3\" name=\"__codelineno-39-3\" href=\"#__codelineno-39-3\"></a><span class=\"w\"> </span><span class=\"nx\">aria</span><span class=\"o\">-</span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"sb\">`Page status: </span><span class=\"si\">${</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'published'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'draft'</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">}</span> </span><span id=\"__span-39-4\"><a id=\"__codelineno-39-4\" name=\"__codelineno-39-4\" href=\"#__codelineno-39-4\"></a><span class=\"o\">></span> </span><span id=\"__span-39-5\"><a id=\"__codelineno-39-5\" name=\"__codelineno-39-5\" href=\"#__codelineno-39-5\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'Published'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Draft'</span><span class=\"p\">}</span> </span><span id=\"__span-39-6\"><a id=\"__codelineno-39-6\" name=\"__codelineno-39-6\" href=\"#__codelineno-39-6\"></a><span class=\"o\"><</span><span class=\"err\">/Tag></span> </span></code></pre></div></p> <h3 id=\"color-contrast\">Color Contrast<a class=\"headerlink\" href=\"#color-contrast\" title=\"Permanent link\">\u00b6</a></h3> <p>All color-coded elements meet WCAG AA standards:</p> <p><strong>Status Tags:</strong> - Published (green): <code>#52c41a</code> on white = 3.0:1 contrast (AA for large text) - Draft (gray): <code>#d9d9d9</code> on white = 2.6:1 contrast (AA for large text)</p> <p><strong>Editor Tags:</strong> - Visual (green): <code>#52c41a</code> on white = 3.0:1 contrast (AA for large text) - Code (blue): <code>#1890ff</code> on white = 4.5:1 contrast (AA)</p> <h2 id=\"troubleshooting\">Troubleshooting<a class=\"headerlink\" href=\"#troubleshooting\" title=\"Permanent link\">\u00b6</a></h2> <h3 id=\"sync-overrides-finds-no-new-pages\">Sync Overrides Finds No New Pages<a class=\"headerlink\" href=\"#sync-overrides-finds-no-new-pages\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Problem:</strong> Click \"Sync Overrides\", get message \"No new overrides to sync\", but you know you added HTML files to <code>mkdocs/docs/overrides/</code>.</p> <p><strong>Diagnosis:</strong></p> <p>Check override directory:</p> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-40-1\"><a id=\"__codelineno-40-1\" name=\"__codelineno-40-1\" href=\"#__codelineno-40-1\"></a>ls<span class=\"w\"> </span>mkdocs/docs/overrides/ </span></code></pre></div> <p>Expected: HTML files present</p> <p>Check if pages already exist in database:</p> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-41-1\"><a id=\"__codelineno-41-1\" name=\"__codelineno-41-1\" href=\"#__codelineno-41-1\"></a>docker<span class=\"w\"> </span>compose<span class=\"w\"> </span><span class=\"nb\">exec</span><span class=\"w\"> </span>v2-postgres<span class=\"w\"> </span>psql<span class=\"w\"> </span>-U<span class=\"w\"> </span>postgres<span class=\"w\"> </span>-d<span class=\"w\"> </span>v2<span class=\"w\"> </span>-c<span class=\"w\"> </span><span class=\"s2\">\"SELECT title, mkdocsPath FROM \\\"LandingPage\\\" WHERE \\\"mkdocsPath\\\" IS NOT NULL\"</span> </span></code></pre></div> <p><strong>Possible Causes:</strong></p> <ol> <li><strong>Files in wrong directory:</strong></li> <li>Files added to <code>mkdocs/docs/</code> instead of <code>mkdocs/docs/overrides/</code></li> <li> <p>API scans <code>overrides/</code> directory only</p> </li> <li> <p><strong>Pages already synced:</strong></p> </li> <li>Override files already imported in previous sync</li> <li> <p>Sync only imports new files, not existing ones</p> </li> <li> <p><strong>Invalid HTML files:</strong></p> </li> <li>Files have wrong extension (e.g., <code>.htm</code> instead of <code>.html</code>)</li> <li>Files are empty or corrupted</li> </ol> <p><strong>Solution:</strong></p> <ol> <li> <p><strong>Move files to correct directory:</strong> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-42-1\"><a id=\"__codelineno-42-1\" name=\"__codelineno-42-1\" href=\"#__codelineno-42-1\"></a>mv<span class=\"w\"> </span>mkdocs/docs/about.html<span class=\"w\"> </span>mkdocs/docs/overrides/ </span></code></pre></div></p> </li> <li> <p><strong>Force re-import (delete page records first):</strong> <div class=\"language-sql highlight\"><pre><span></span><code><span id=\"__span-43-1\"><a id=\"__codelineno-43-1\" name=\"__codelineno-43-1\" href=\"#__codelineno-43-1\"></a><span class=\"k\">DELETE</span><span class=\"w\"> </span><span class=\"k\">FROM</span><span class=\"w\"> </span><span class=\"ss\">\"LandingPage\"</span><span class=\"w\"> </span><span class=\"k\">WHERE</span><span class=\"w\"> </span><span class=\"ss\">\"mkdocsPath\"</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"s1\">'about.html'</span><span class=\"p\">;</span> </span></code></pre></div> Then click \"Sync Overrides\" again</p> </li> <li> <p><strong>Check file extensions:</strong> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-44-1\"><a id=\"__codelineno-44-1\" name=\"__codelineno-44-1\" href=\"#__codelineno-44-1\"></a>find<span class=\"w\"> </span>mkdocs/docs/overrides/<span class=\"w\"> </span>-type<span class=\"w\"> </span>f<span class=\"w\"> </span>!<span class=\"w\"> </span>-name<span class=\"w\"> </span><span class=\"s2\">\"*.html\"</span> </span></code></pre></div> Rename files to <code>.html</code> extension</p> </li> </ol> <hr /> <h3 id=\"validate-exports-shows-errors\">Validate Exports Shows Errors<a class=\"headerlink\" href=\"#validate-exports-shows-errors\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Problem:</strong> Click \"Validate Exports\", get message \"Validated 24 pages: 0 repaired, 3 errors\".</p> <p><strong>Diagnosis:</strong></p> <p>Check API logs for error details:</p> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-45-1\"><a id=\"__codelineno-45-1\" name=\"__codelineno-45-1\" href=\"#__codelineno-45-1\"></a>docker<span class=\"w\"> </span>compose<span class=\"w\"> </span>logs<span class=\"w\"> </span>api<span class=\"w\"> </span><span class=\"p\">|</span><span class=\"w\"> </span>grep<span class=\"w\"> </span><span class=\"s2\">\"validate\"</span> </span></code></pre></div> <p>Expected error messages:</p> <div class=\"language-text highlight\"><pre><span></span><code><span id=\"__span-46-1\"><a id=\"__codelineno-46-1\" name=\"__codelineno-46-1\" href=\"#__codelineno-46-1\"></a>Page broken-page: Invalid HTML: Unclosed <div> tag </span><span id=\"__span-46-2\"><a id=\"__codelineno-46-2\" name=\"__codelineno-46-2\" href=\"#__codelineno-46-2\"></a>Page test-page: Write error: EACCES: permission denied, open 'mkdocs/docs/overrides/test-page.html' </span></code></pre></div> <p><strong>Possible Causes:</strong></p> <ol> <li><strong>Invalid HTML:</strong></li> <li>Page content has syntax errors (unclosed tags, invalid attributes)</li> <li> <p>Cannot export to MkDocs (would break theme)</p> </li> <li> <p><strong>Write permissions:</strong></p> </li> <li>API container cannot write to <code>mkdocs/docs/overrides/</code> directory</li> <li> <p>Filesystem permissions issue</p> </li> <li> <p><strong>Missing parent directory:</strong></p> </li> <li>Custom mkdocsPath references subdirectory (e.g., \"pages/about.html\")</li> <li>Subdirectory <code>mkdocs/docs/overrides/pages/</code> doesn't exist</li> </ol> <p><strong>Solution:</strong></p> <ol> <li><strong>Fix invalid HTML:</strong></li> <li>Navigate to <code>/app/pages/:id/edit</code></li> <li>Fix syntax errors in editor</li> <li>Save changes</li> <li> <p>Click \"Validate Exports\" again</p> </li> <li> <p><strong>Fix write permissions:</strong> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-47-1\"><a id=\"__codelineno-47-1\" name=\"__codelineno-47-1\" href=\"#__codelineno-47-1\"></a>sudo<span class=\"w\"> </span>chown<span class=\"w\"> </span>-R<span class=\"w\"> </span><span class=\"m\">1000</span>:1000<span class=\"w\"> </span>mkdocs/docs/overrides/ </span><span id=\"__span-47-2\"><a id=\"__codelineno-47-2\" name=\"__codelineno-47-2\" href=\"#__codelineno-47-2\"></a>sudo<span class=\"w\"> </span>chmod<span class=\"w\"> </span>-R<span class=\"w\"> </span><span class=\"m\">755</span><span class=\"w\"> </span>mkdocs/docs/overrides/ </span></code></pre></div></p> </li> <li> <p><strong>Create missing directories:</strong> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-48-1\"><a id=\"__codelineno-48-1\" name=\"__codelineno-48-1\" href=\"#__codelineno-48-1\"></a>mkdir<span class=\"w\"> </span>-p<span class=\"w\"> </span>mkdocs/docs/overrides/pages </span></code></pre></div></p> </li> </ol> <hr /> <h3 id=\"build-site-button-not-visible\">Build Site Button Not Visible<a class=\"headerlink\" href=\"#build-site-button-not-visible\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Problem:</strong> Cannot see \"Build Site\" button in page list.</p> <p><strong>Diagnosis:</strong></p> <p>Check user role:</p> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-49-1\"><a id=\"__codelineno-49-1\" name=\"__codelineno-49-1\" href=\"#__codelineno-49-1\"></a>docker<span class=\"w\"> </span>compose<span class=\"w\"> </span><span class=\"nb\">exec</span><span class=\"w\"> </span>v2-postgres<span class=\"w\"> </span>psql<span class=\"w\"> </span>-U<span class=\"w\"> </span>postgres<span class=\"w\"> </span>-d<span class=\"w\"> </span>v2<span class=\"w\"> </span>-c<span class=\"w\"> </span><span class=\"s2\">\"SELECT email, role FROM \\\"User\\\" WHERE email = 'your-email@example.com'\"</span> </span></code></pre></div> <p>Expected: <code>role = 'SUPER_ADMIN'</code></p> <p><strong>Possible Causes:</strong></p> <ol> <li><strong>Insufficient role:</strong></li> <li>User role is not SUPER_ADMIN</li> <li> <p>Build Site button only visible to SUPER_ADMIN</p> </li> <li> <p><strong>Frontend cache:</strong></p> </li> <li>User role changed but frontend still using old auth token</li> <li>Need to refresh token or log out/in</li> </ol> <p><strong>Solution:</strong></p> <ol> <li> <p><strong>Upgrade user role:</strong> <div class=\"language-sql highlight\"><pre><span></span><code><span id=\"__span-50-1\"><a id=\"__codelineno-50-1\" name=\"__codelineno-50-1\" href=\"#__codelineno-50-1\"></a><span class=\"k\">UPDATE</span><span class=\"w\"> </span><span class=\"ss\">\"User\"</span><span class=\"w\"> </span><span class=\"k\">SET</span><span class=\"w\"> </span><span class=\"k\">role</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"s1\">'SUPER_ADMIN'</span><span class=\"w\"> </span><span class=\"k\">WHERE</span><span class=\"w\"> </span><span class=\"n\">email</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"s1\">'your-email@example.com'</span><span class=\"p\">;</span> </span></code></pre></div></p> </li> <li> <p><strong>Refresh auth token:</strong></p> </li> <li>Log out</li> <li>Log back in</li> <li>Role check updates with new token</li> </ol> <hr /> <h3 id=\"mkdocs-build-fails\">MkDocs Build Fails<a class=\"headerlink\" href=\"#mkdocs-build-fails\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Problem:</strong> Click \"Build Site\", get error message \"Failed to build site\".</p> <p><strong>Diagnosis:</strong></p> <p>Check MkDocs container logs:</p> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-51-1\"><a id=\"__codelineno-51-1\" name=\"__codelineno-51-1\" href=\"#__codelineno-51-1\"></a>docker<span class=\"w\"> </span>compose<span class=\"w\"> </span>logs<span class=\"w\"> </span>mkdocs </span></code></pre></div> <p>Common error messages:</p> <div class=\"language-text highlight\"><pre><span></span><code><span id=\"__span-52-1\"><a id=\"__codelineno-52-1\" name=\"__codelineno-52-1\" href=\"#__codelineno-52-1\"></a>ERROR - Config file 'mkdocs.yml' does not exist </span><span id=\"__span-52-2\"><a id=\"__codelineno-52-2\" name=\"__codelineno-52-2\" href=\"#__codelineno-52-2\"></a>ERROR - Invalid value: 'material' is not installed </span><span id=\"__span-52-3\"><a id=\"__codelineno-52-3\" name=\"__codelineno-52-3\" href=\"#__codelineno-52-3\"></a>ERROR - Template not found: overrides/about.html </span></code></pre></div> <p><strong>Possible Causes:</strong></p> <ol> <li><strong>MkDocs container down:</strong></li> <li>MkDocs service not running</li> <li> <p>Cannot execute build command</p> </li> <li> <p><strong>Configuration error:</strong></p> </li> <li><code>mkdocs.yml</code> has syntax errors</li> <li> <p>Invalid theme or plugin configuration</p> </li> <li> <p><strong>Missing theme:</strong></p> </li> <li>Material theme not installed in MkDocs container</li> <li> <p>Need to rebuild container with theme</p> </li> <li> <p><strong>Missing override files:</strong></p> </li> <li>Markdown stubs reference override files that don't exist</li> <li>Need to run \"Validate Exports\" first</li> </ol> <p><strong>Solution:</strong></p> <ol> <li> <p><strong>Start MkDocs container:</strong> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-53-1\"><a id=\"__codelineno-53-1\" name=\"__codelineno-53-1\" href=\"#__codelineno-53-1\"></a>docker<span class=\"w\"> </span>compose<span class=\"w\"> </span>up<span class=\"w\"> </span>-d<span class=\"w\"> </span>mkdocs </span></code></pre></div></p> </li> <li> <p><strong>Fix configuration errors:</strong></p> </li> <li>Edit <code>mkdocs/mkdocs.yml</code></li> <li>Fix syntax errors (check YAML indentation)</li> <li> <p>Test locally: <code>cd mkdocs && mkdocs build</code></p> </li> <li> <p><strong>Rebuild container with theme:</strong> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-54-1\"><a id=\"__codelineno-54-1\" name=\"__codelineno-54-1\" href=\"#__codelineno-54-1\"></a>docker<span class=\"w\"> </span>compose<span class=\"w\"> </span>build<span class=\"w\"> </span>mkdocs </span><span id=\"__span-54-2\"><a id=\"__codelineno-54-2\" name=\"__codelineno-54-2\" href=\"#__codelineno-54-2\"></a>docker<span class=\"w\"> </span>compose<span class=\"w\"> </span>up<span class=\"w\"> </span>-d<span class=\"w\"> </span>mkdocs </span></code></pre></div></p> </li> <li> <p><strong>Run validation first:</strong></p> </li> <li>Click \"Validate Exports\" to repair missing files</li> <li>Then click \"Build Site\"</li> </ol> <h2 id=\"related-documentation\">Related Documentation<a class=\"headerlink\" href=\"#related-documentation\" title=\"Permanent link\">\u00b6</a></h2> <ul> <li><a href=\"/v2/backend/modules/pages.md\">Landing Pages Backend Module</a> \u2014 Backend page service</li> <li><a href=\"/v2/api-reference/pages.md\">Landing Pages API Reference</a> \u2014 Page endpoints</li> <li><a href=\"/v2/frontend/components/grapesjs-editor.md\">GrapesJS Editor Component</a> \u2014 Visual editor wrapper</li> <li><a href=\"/v2/frontend/pages/admin/page-editor-page.md\">Page Editor Page</a> \u2014 Full-screen page editor</li> <li><a href=\"/v2/frontend/pages/public/landing-page.md\">Public Landing Page</a> \u2014 Public page renderer at /p/:slug</li> <li><a href=\"/v2/features/pages/mkdocs-integration.md\">MkDocs Integration</a> \u2014 MkDocs export + Material theme</li> <li><a href=\"/v2/frontend/pages/admin/docs-page.md\">DocsPage</a> \u2014 MkDocs management (site building, export table)</li> <li><a href=\"/v2/user-guides/content-editor-guide.md\">User Guide: Content Editor</a> \u2014 Landing page creation workflow</li> <li><a href=\"/v2/troubleshooting/mkdocs-issues.md\">Troubleshooting: MkDocs Issues</a> \u2014 MkDocs troubleshooting</li> </ul>"},{"location":"v2/frontend/pages/admin/listmonk-page/","title":"ListmonkPage","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#overview","title":"Overview","text":"

The ListmonkPage provides administrative management of the Listmonk newsletter integration, offering a dual-view interface with management controls (sync, status monitoring) on one tab and an embedded Listmonk admin interface on another. It enables synchronization of campaign participants, map locations, and users to Listmonk subscriber lists, monitors connection status, displays list statistics with subscriber counts, and provides advanced operations like reinitialization and connection testing. The embedded admin tab loads the full Listmonk web UI via iframe with auto-authentication, allowing direct management of campaigns, subscribers, and templates without leaving the admin interface.

Route: /app/listmonk Component: admin/src/pages/ListmonkPage.tsx (395 lines) Auth Required: Yes (SUPER_ADMIN or INFLUENCE_ADMIN role recommended) Layout: AppLayout Backend Module: api/src/modules/listmonk/

"},{"location":"v2/frontend/pages/admin/listmonk-page/#screenshot","title":"Screenshot","text":"

[Screenshot: ListmonkPage with \"Newsletter / Listmonk\" title. Right side has tab switcher (Management selected, Listmonk Admin grayed), \"Test Connection\" button, and \"Open Listmonk\" button (opens in new tab). Below are two cards side-by-side: \"Status\" card shows Sync Enabled (green checkmark), Connection (green Connected), Lists Initialized (green Yes), Last Sync (2 minutes ago), Last Error (None). \"Sync Actions\" card has 4 buttons in 2\u00d72 grid: \"Sync Participants\", \"Sync Locations\", \"Sync Users\", \"Sync All\" (primary blue). Below is \"List Statistics\" card with table showing List Name column (Participants, Locations, Users) and Subscribers column (347, 203, 15). At bottom is collapsed \"Advanced\" section. When \"Listmonk Admin\" tab selected, full Listmonk UI loads in iframe with dark theme, showing campaigns list, subscribers count, and send email button.]

"},{"location":"v2/frontend/pages/admin/listmonk-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#checking-sync-status","title":"Checking Sync Status","text":"
  1. Navigate to /app/listmonk
  2. Ensure \"Management\" tab is selected (default)
  3. Observe \"Status\" card (left column):
  4. Sync Enabled: Badge shows Enabled (green) or Disabled (red)
  5. Connection: Badge shows Connected (green), Disconnected (orange), or N/A (gray)
  6. Lists Initialized: Badge shows Yes (green) or No (gray)
  7. Last Sync: Relative time (e.g., \"2 minutes ago\") or \"Never\"
  8. Last Error: Error message or \"None\"
  9. Check \"List Statistics\" table:
  10. Participants: Subscriber count for campaign participants
  11. Locations: Subscriber count for map locations
  12. Users: Subscriber count for user accounts

Sync Enabled States: - Enabled (green): LISTMONK_SYNC_ENABLED=true in .env, sync operations allowed - Disabled (red): LISTMONK_SYNC_ENABLED=false in .env, sync operations blocked

Connection States: - Connected (green): Listmonk API reachable, credentials valid - Disconnected (orange): Listmonk API unreachable or credentials invalid (only shown if sync enabled) - N/A (gray): Sync disabled, connection not tested

Lists Initialized: - Yes (green): Listmonk lists (Participants, Locations, Users) exist and ready - No (gray): Lists not yet created (click \"Reinitialize Lists\" to create)

"},{"location":"v2/frontend/pages/admin/listmonk-page/#testing-listmonk-connection","title":"Testing Listmonk Connection","text":"

When to Test: - Before first sync (verify credentials) - After updating Listmonk URL/credentials - Troubleshooting sync failures

Steps:

  1. Click \"Test Connection\" button (top-right header)
  2. Loading spinner appears on button
  3. Backend tests Listmonk API connection:
  4. GET /api/health endpoint
  5. Verifies basic auth credentials
  6. Checks API version compatibility
  7. Result message appears:
  8. Success: \"Connection successful\" (green toast)
  9. Warning: \"Connection partially successful - check configuration\" (orange toast)
  10. Error: \"Connection failed - check Listmonk URL and credentials\" (red toast)
  11. Status card refreshes to show updated connection state

Success Criteria: - Listmonk API responds to /api/health endpoint - Credentials (username/password) authenticate successfully - API version is compatible (v2.0+)

"},{"location":"v2/frontend/pages/admin/listmonk-page/#syncing-participants-to-listmonk","title":"Syncing Participants to Listmonk","text":"

What is \"Participants\"?

Campaign participants who submitted responses via the response wall. Synced to Listmonk \"Participants\" list for newsletter targeting.

Steps:

  1. Click \"Sync Participants\" button in \"Sync Actions\" card
  2. Loading spinner appears on button
  3. Backend fetches all campaign participants from database:
  4. Query: SELECT DISTINCT email, name FROM Response WHERE verified = true
  5. Filter: Only verified responses (email confirmed)
  6. For each participant:
  7. Check if subscriber exists in Listmonk \"Participants\" list
  8. If not exists, create new subscriber with name and email
  9. If exists, update subscriber attributes (last campaign, response count)
  10. Result message appears:
  11. Success: \"Synced participants: 347 created, 23 updated\"
  12. Warning: \"Synced participants: 347 created, 23 updated, 5 failed - check logs\"
  13. Status card and list statistics update to show new counts

Sync Logic:

// Fetch participants from database\nconst participants = await prisma.response.findMany({\n  where: { verified: true },\n  distinct: ['email'],\n  select: { email: true, name: true, campaignId: true },\n});\n\n// For each participant\nfor (const participant of participants) {\n  try {\n    // Check if subscriber exists\n    const existingSubscriber = await listmonkClient.getSubscriberByEmail(participant.email);\n\n    if (existingSubscriber) {\n      // Update existing subscriber\n      await listmonkClient.updateSubscriber(existingSubscriber.id, {\n        name: participant.name,\n        attribs: { lastCampaign: participant.campaignId },\n      });\n      updated++;\n    } else {\n      // Create new subscriber\n      await listmonkClient.createSubscriber({\n        email: participant.email,\n        name: participant.name,\n        lists: [participantsListId],\n        attribs: { source: 'campaign_response' },\n      });\n      created++;\n    }\n  } catch (error) {\n    failed++;\n  }\n}\n
"},{"location":"v2/frontend/pages/admin/listmonk-page/#syncing-locations-to-listmonk","title":"Syncing Locations to Listmonk","text":"

What is \"Locations\"?

Map locations (residential addresses, campaign offices, etc.). Synced to Listmonk \"Locations\" list for geographic targeting.

Steps:

  1. Click \"Sync Locations\" button in \"Sync Actions\" card
  2. Loading spinner appears on button
  3. Backend fetches all locations with valid email addresses:
  4. Query: SELECT * FROM Location WHERE email IS NOT NULL AND deletedAt IS NULL
  5. Filter: Only locations with email, not soft-deleted
  6. For each location:
  7. Check if subscriber exists in Listmonk \"Locations\" list
  8. If not exists, create new subscriber with address details
  9. If exists, update subscriber attributes (address, postal code, cut)
  10. Result message appears:
  11. Success: \"Synced locations: 203 created, 45 updated\"
  12. Status card and list statistics update

Subscriber Attributes:

{\n  email: location.email,\n  name: location.name || location.address,  // Fallback to address if no name\n  lists: [locationsListId],\n  attribs: {\n    address: location.address,\n    postalCode: location.postalCode,\n    city: location.city,\n    cutId: location.cutId,\n    province: location.province,\n  },\n}\n
"},{"location":"v2/frontend/pages/admin/listmonk-page/#syncing-users-to-listmonk","title":"Syncing Users to Listmonk","text":"

What is \"Users\"?

User accounts (admins, volunteers, etc.). Synced to Listmonk \"Users\" list for internal communications.

Steps:

  1. Click \"Sync Users\" button in \"Sync Actions\" card
  2. Loading spinner appears on button
  3. Backend fetches all user accounts:
  4. Query: SELECT * FROM User WHERE deletedAt IS NULL
  5. Filter: Exclude soft-deleted users
  6. For each user:
  7. Check if subscriber exists in Listmonk \"Users\" list
  8. If not exists, create new subscriber with role info
  9. If exists, update subscriber attributes (role, last login)
  10. Result message appears:
  11. Success: \"Synced users: 15 created, 3 updated\"
  12. Status card and list statistics update

Subscriber Attributes:

{\n  email: user.email,\n  name: user.name,\n  lists: [usersListId],\n  attribs: {\n    role: user.role,\n    lastLogin: user.lastLogin,\n  },\n}\n
"},{"location":"v2/frontend/pages/admin/listmonk-page/#syncing-all-lists-at-once","title":"Syncing All Lists at Once","text":"

When to Use: - Initial setup (populate all lists) - After bulk data import (NAR import, CSV import) - Regular maintenance (weekly/monthly sync)

Steps:

  1. Click \"Sync All\" button (primary blue, bottom-right of \"Sync Actions\" card)
  2. Loading spinner appears on button
  3. Backend syncs all three lists sequentially:
  4. First: Sync participants
  5. Second: Sync locations
  6. Third: Sync users
  7. Result message shows aggregated counts:
  8. Success: \"Synced all lists: 347 participants, 203 locations, 15 users\"
  9. Warning: \"Synced all lists: 565 total, 8 failed - check logs\"
  10. Status card and list statistics update to show all new counts

Performance:

"},{"location":"v2/frontend/pages/admin/listmonk-page/#reinitializing-listmonk-lists","title":"Reinitializing Listmonk Lists","text":"

When to Reinitialize: - Lists accidentally deleted in Listmonk - Fresh Listmonk installation - Corrupted list data

Steps:

  1. Scroll to \"Advanced\" section at bottom
  2. Click to expand \"Advanced\" collapse panel
  3. Click \"Reinitialize Lists\" button
  4. Confirmation popconfirm appears: \"Reinitialize Lists. This will re-create any missing lists in Listmonk. Existing lists are preserved.\"
  5. Click \"Reinitialize\" to confirm (or click outside to cancel)
  6. Loading spinner appears on button
  7. Backend checks for existence of each list (Participants, Locations, Users)
  8. For each missing list:
  9. Create new list with name and type (public/private)
  10. Set list description
  11. Success message: \"Lists reinitialized\" (or error if creation fails)
  12. Status card updates to show \"Lists Initialized: Yes\"

Important: Reinitialization only creates missing lists. Existing lists are NOT deleted or modified. Existing subscribers remain intact.

"},{"location":"v2/frontend/pages/admin/listmonk-page/#accessing-embedded-listmonk-admin","title":"Accessing Embedded Listmonk Admin","text":"

What is \"Listmonk Admin\" Tab?

Full Listmonk web UI embedded in iframe, allowing direct management without leaving admin interface.

Steps:

  1. Click \"Listmonk Admin\" button in tab switcher (top-right header)
  2. Active tab changes from \"Management\" to \"Listmonk Admin\"
  3. Page layout changes to fullbleed (removes padding for full-screen iframe)
  4. Loading spinner appears while iframe loads
  5. Backend generates auto-authentication token:
  6. GET /api/listmonk/proxy-url
  7. Response: { port: 9001, token: \"auto-auth-token-xyz\" }
  8. Iframe loads Listmonk URL with auth token:
  9. URL: //localhost:9001/auth?token=auto-auth-token-xyz
  10. Listmonk auto-authenticates user (no manual login required)
  11. Full Listmonk UI appears in iframe:
  12. Dashboard (campaign stats, subscriber counts)
  13. Campaigns (create/send newsletters)
  14. Subscribers (view/edit/import)
  15. Lists (manage subscriber lists)
  16. Templates (email templates with WYSIWYG editor)

Use Cases: - Create newsletter campaigns - View/edit subscribers directly - Import subscribers from CSV - Design email templates - View campaign analytics

Limitations: - Iframe may have slight performance overhead vs. direct access - Some Listmonk features may require full-screen (use \"Open Listmonk\" button instead)

"},{"location":"v2/frontend/pages/admin/listmonk-page/#opening-listmonk-in-new-tab","title":"Opening Listmonk in New Tab","text":"

When to Use: - Full-screen Listmonk access (no iframe constraints) - Better performance (no iframe overhead) - Working with large subscriber lists (better scrolling)

Steps:

  1. Click \"Open Listmonk\" button (top-right header, next to \"Test Connection\")
  2. New browser tab opens with Listmonk URL: //localhost:9001
  3. Listmonk login page appears (if not already logged in)
  4. Enter Listmonk admin credentials:
  5. Username: Value of LISTMONK_WEB_ADMIN_USER env var
  6. Password: Value of LISTMONK_WEB_ADMIN_PASSWORD env var
  7. Click \"Login\" to access full Listmonk interface

Note: This opens Listmonk directly on port 9001. User must manually authenticate (no auto-auth token). Use this for full-featured access without iframe restrictions.

"},{"location":"v2/frontend/pages/admin/listmonk-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#ant-design-components-used","title":"Ant Design Components Used","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#dual-view-tab-switcher","title":"Dual-View Tab Switcher","text":"
<Radio.Group\n  value={activeTab}\n  onChange={(e) => {\n    const tab = e.target.value as 'management' | 'admin';\n    setActiveTab(tab);\n    if (tab === 'admin') loadIframe();  // Lazy-load iframe\n  }}\n  optionType=\"button\"\n  buttonStyle=\"solid\"\n  size=\"small\"\n>\n  <Radio.Button value=\"management\">\n    <SettingOutlined /> Management\n  </Radio.Button>\n  <Radio.Button value=\"admin\">\n    <DesktopOutlined /> Listmonk Admin\n  </Radio.Button>\n</Radio.Group>\n

Tab Switcher Features: - Button style: Solid background for selected tab (more prominent than default) - Icons: Visual indicators (Settings for Management, Desktop for Admin) - Lazy loading: Iframe only loads when Admin tab selected (performance optimization) - Size small: Compact header controls

"},{"location":"v2/frontend/pages/admin/listmonk-page/#status-card","title":"Status Card","text":"
<Card title=\"Status\" size=\"small\">\n  <Descriptions column={1} size=\"small\">\n    <Descriptions.Item label=\"Sync Enabled\">\n      <Badge\n        status={status?.enabled ? 'success' : 'error'}\n        text={status?.enabled ? 'Enabled' : 'Disabled'}\n      />\n    </Descriptions.Item>\n    <Descriptions.Item label=\"Connection\">\n      <Badge\n        status={status?.connected ? 'success' : status?.enabled ? 'warning' : 'default'}\n        text={status?.connected ? 'Connected' : status?.enabled ? 'Disconnected' : 'N/A'}\n      />\n    </Descriptions.Item>\n    <Descriptions.Item label=\"Lists Initialized\">\n      <Badge\n        status={status?.initialized ? 'success' : 'default'}\n        text={status?.initialized ? 'Yes' : 'No'}\n      />\n    </Descriptions.Item>\n    <Descriptions.Item label=\"Last Sync\">\n      {status?.lastSyncAt ? dayjs(status.lastSyncAt).fromNow() : 'Never'}\n    </Descriptions.Item>\n    <Descriptions.Item label=\"Last Error\">\n      {status?.lastError || 'None'}\n    </Descriptions.Item>\n  </Descriptions>\n</Card>\n

Status Badge Colors: - Success (green dot): Enabled, Connected, Initialized=Yes - Error (red dot): Disabled - Warning (orange dot): Enabled but Disconnected - Default (gray dot): N/A (sync disabled), Initialized=No

"},{"location":"v2/frontend/pages/admin/listmonk-page/#sync-actions-card","title":"Sync Actions Card","text":"
<Card title=\"Sync Actions\" size=\"small\">\n  <Space direction=\"vertical\" style={{ width: '100%' }} size=\"middle\">\n    <Row gutter={[8, 8]}>\n      <Col xs={24} sm={12}>\n        <Button\n          block\n          icon={<SyncOutlined />}\n          loading={syncing.participants}\n          onClick={() => handleSync('participants')}\n          disabled={!status?.enabled}\n        >\n          Sync Participants\n        </Button>\n      </Col>\n      <Col xs={24} sm={12}>\n        <Button\n          block\n          icon={<SyncOutlined />}\n          loading={syncing.locations}\n          onClick={() => handleSync('locations')}\n          disabled={!status?.enabled}\n        >\n          Sync Locations\n        </Button>\n      </Col>\n      <Col xs={24} sm={12}>\n        <Button\n          block\n          icon={<SyncOutlined />}\n          loading={syncing.users}\n          onClick={() => handleSync('users')}\n          disabled={!status?.enabled}\n        >\n          Sync Users\n        </Button>\n      </Col>\n      <Col xs={24} sm={12}>\n        <Button\n          block\n          type=\"primary\"\n          icon={<SyncOutlined />}\n          loading={syncing.all}\n          onClick={handleSyncAll}\n          disabled={!status?.enabled}\n        >\n          Sync All\n        </Button>\n      </Col>\n    </Row>\n  </Space>\n</Card>\n

Sync Actions Features: - Block buttons: Full-width buttons for easy clicking - 2\u00d72 grid: Responsive layout (stacked on mobile, side-by-side on desktop) - Individual loading states: Each button has its own loading spinner - Disabled state: Buttons disabled if sync not enabled in .env - Primary styling: \"Sync All\" button uses primary blue (most common action)

"},{"location":"v2/frontend/pages/admin/listmonk-page/#list-statistics-table","title":"List Statistics Table","text":"
<Table\n  dataSource={stats?.lists || []}\n  rowKey=\"name\"\n  size=\"small\"\n  loading={loading}\n  pagination={false}\n  columns={[\n    { title: 'List Name', dataIndex: 'name', key: 'name' },\n    {\n      title: 'Subscribers',\n      dataIndex: 'subscriberCount',\n      key: 'subscriberCount',\n      width: 120,\n      align: 'right' as const,\n    },\n  ]}\n  locale={{\n    emptyText: status?.initialized\n      ? 'No lists found'\n      : 'Lists not initialized \u2014 run a sync or reinitialize',\n  }}\n/>\n

Table Features: - Small size: Compact rows for dashboard-style display - No pagination: Only 3 lists (Participants, Locations, Users), always fit on one page - Right-aligned numbers: Subscriber counts right-aligned for easier comparison - Custom empty text: Different message if lists not initialized vs. genuinely empty

"},{"location":"v2/frontend/pages/admin/listmonk-page/#embedded-listmonk-iframe","title":"Embedded Listmonk Iframe","text":"
{activeTab === 'admin' && (\n  <div>\n    {iframeLoading && (\n      <div style={{ textAlign: 'center', padding: 80 }}>\n        <Spin size=\"large\" />\n      </div>\n    )}\n    {iframeError && (\n      <Alert\n        type=\"error\"\n        message={iframeError}\n        showIcon\n        action={\n          <Button size=\"small\" onClick={loadIframe}>\n            Retry\n          </Button>\n        }\n        style={{ marginBottom: 16 }}\n      />\n    )}\n    {iframeSrc && !iframeLoading && (\n      <iframe\n        src={iframeSrc}\n        style={{\n          width: '100%',\n          height: 'calc(100vh - 64px)',  // Full viewport height minus header\n          border: 'none',\n          display: 'block',\n        }}\n        title=\"Listmonk Admin\"\n      />\n    )}\n  </div>\n)}\n

Iframe Features: - Full viewport height: calc(100vh - 64px) fills available space - No border: Seamless integration with admin interface - Loading state: Large spinner while iframe loads - Error handling: Alert with retry button if iframe fails to load - Auto-authentication: Token in URL query string (?token=xyz) logs user in automatically

"},{"location":"v2/frontend/pages/admin/listmonk-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#local-state-no-zustand-store","title":"Local State (No Zustand Store)","text":"
const [status, setStatus] = useState<ListmonkStatus | null>(null);\nconst [stats, setStats] = useState<ListmonkStats | null>(null);\nconst [loading, setLoading] = useState(true);\nconst [syncing, setSyncing] = useState<Record<string, boolean>>({});\nconst [iframeSrc, setIframeSrc] = useState<string | null>(null);\nconst [iframeLoading, setIframeLoading] = useState(false);\nconst [iframeError, setIframeError] = useState<string | null>(null);\nconst iframeInitialized = useRef(false);\nconst [activeTab, setActiveTab] = useState<'management' | 'admin'>('management');\n

State Variables: - status (object | null): Sync status (enabled, connected, initialized, lastSyncAt, lastError) - stats (object | null): List statistics (lists array with name and subscriberCount) - loading (boolean): Initial page load state - syncing (object): Sync button loading states (participants, locations, users, all, test, reinit) - iframeSrc (string | null): Listmonk iframe URL with auth token - iframeLoading (boolean): Iframe loading state - iframeError (string | null): Iframe load error message - iframeInitialized (ref): Prevents redundant iframe loads (only load once) - activeTab (string): Currently active tab ('management' or 'admin')

No Global State:

This page does NOT use Zustand stores. Listmonk data is fetched directly from the API and stored in local state. This is appropriate because: - Listmonk data is admin-only - Data changes infrequently (manual sync operations) - No need to share state between pages - Simpler architecture without store overhead

"},{"location":"v2/frontend/pages/admin/listmonk-page/#lazy-iframe-loading","title":"Lazy Iframe Loading","text":"

Iframe only loads when Admin tab is selected:

const loadIframe = useCallback(async () => {\n  if (iframeInitialized.current && iframeSrc) return;  // Already loaded, skip\n\n  setIframeLoading(true);\n  setIframeError(null);\n  try {\n    const res = await api.get<{ port: number; token: string }>('/listmonk/proxy-url');\n    const { port, token } = res.data;\n    const url = `//${window.location.hostname}:${port}/auth?token=${encodeURIComponent(token)}`;\n    setIframeSrc(url);\n    iframeInitialized.current = true;  // Mark as initialized\n  } catch {\n    setIframeError('Failed to load Listmonk admin \u2014 ensure the proxy is running');\n  } finally {\n    setIframeLoading(false);\n  }\n}, [iframeSrc]);\n\n// Load iframe when Admin tab selected\nonChange={(e) => {\n  const tab = e.target.value as 'management' | 'admin';\n  setActiveTab(tab);\n  if (tab === 'admin') loadIframe();\n}}\n

Why Lazy Load?

"},{"location":"v2/frontend/pages/admin/listmonk-page/#fullbleed-layout-for-iframe","title":"Fullbleed Layout for Iframe","text":"

When Admin tab is active, page header sets fullBleed: true:

useEffect(() => {\n  setPageHeader({\n    title: 'Newsletter / Listmonk',\n    actions: headerActions,\n    fullBleed: activeTab === 'admin',  // Remove padding for full-screen iframe\n  });\n  return () => setPageHeader(null);\n}, [setPageHeader, headerActions, activeTab]);\n

Result:

"},{"location":"v2/frontend/pages/admin/listmonk-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose Auth GET /api/listmonk Get sync status Required GET /api/listmonk/stats Get list statistics Required POST /api/listmonk/test-connection Test Listmonk API connection Required POST /api/listmonk/sync/participants Sync participants list Required POST /api/listmonk/sync/locations Sync locations list Required POST /api/listmonk/sync/users Sync users list Required POST /api/listmonk/sync/all Sync all lists Required POST /api/listmonk/reinitialize Reinitialize lists Required GET /api/listmonk/proxy-url Get iframe URL with auth token Required"},{"location":"v2/frontend/pages/admin/listmonk-page/#load-sync-status","title":"Load Sync Status","text":"

Request:

const { data } = await api.get<ListmonkStatus>('/listmonk');\n

Response (200 OK):

{\n  \"enabled\": true,\n  \"connected\": true,\n  \"initialized\": true,\n  \"lastSyncAt\": \"2026-02-11T10:30:00.000Z\",\n  \"lastError\": null\n}\n

Response Fields: - enabled (boolean): Value of LISTMONK_SYNC_ENABLED env var - connected (boolean): Listmonk API reachable and credentials valid - initialized (boolean): Listmonk lists (Participants, Locations, Users) exist - lastSyncAt (ISO 8601 | null): Timestamp of last successful sync - lastError (string | null): Last error message (or null if no errors)

"},{"location":"v2/frontend/pages/admin/listmonk-page/#load-list-statistics","title":"Load List Statistics","text":"

Request:

const { data } = await api.get<ListmonkStats>('/listmonk/stats');\n

Response (200 OK):

{\n  \"lists\": [\n    {\n      \"name\": \"Participants\",\n      \"subscriberCount\": 347\n    },\n    {\n      \"name\": \"Locations\",\n      \"subscriberCount\": 203\n    },\n    {\n      \"name\": \"Users\",\n      \"subscriberCount\": 15\n    }\n  ]\n}\n

Response Fields: - lists (array): Array of list objects - name (string): List name (Participants, Locations, or Users) - subscriberCount (number): Number of subscribers in list

Backend Calculation:

const lists = await listmonkClient.getLists();\nconst stats = await Promise.all(\n  lists.map(async (list) => {\n    const count = await listmonkClient.getSubscriberCount(list.id);\n    return { name: list.name, subscriberCount: count };\n  })\n);\nreturn { lists: stats };\n
"},{"location":"v2/frontend/pages/admin/listmonk-page/#test-listmonk-connection","title":"Test Listmonk Connection","text":"

Request:

const { data } = await api.post<{ success: boolean; message: string }>('/listmonk/test-connection');\n

Response (200 OK) - Success:

{\n  \"success\": true,\n  \"message\": \"Connection successful - Listmonk v2.3.0\"\n}\n

Response (200 OK) - Failure:

{\n  \"success\": false,\n  \"message\": \"Connection failed: Authentication error\"\n}\n

Response Fields: - success (boolean): Whether connection test passed - message (string): Result message (success details or error reason)

Backend Test:

try {\n  // Test Listmonk API health endpoint\n  const health = await listmonkClient.getHealth();\n\n  if (health.version) {\n    return { success: true, message: `Connection successful - Listmonk v${health.version}` };\n  } else {\n    return { success: false, message: 'Connection failed: Invalid response' };\n  }\n} catch (error) {\n  return { success: false, message: `Connection failed: ${error.message}` };\n}\n
"},{"location":"v2/frontend/pages/admin/listmonk-page/#sync-participantslocationsusers","title":"Sync Participants/Locations/Users","text":"

Request:

const type = 'participants';  // or 'locations' or 'users'\nconst { data } = await api.post<ListmonkSyncResult>(`/listmonk/sync/${type}`);\n

Response (200 OK):

{\n  \"success\": true,\n  \"message\": \"Synced participants: 347 created, 23 updated\",\n  \"results\": {\n    \"created\": 347,\n    \"updated\": 23,\n    \"failed\": 5\n  }\n}\n

Response Fields: - success (boolean): Whether sync operation completed - message (string): Result summary - results (object): - created (number): New subscribers added - updated (number): Existing subscribers updated - failed (number): Subscribers that failed to sync (API errors, validation errors)

Backend Workflow:

// 1. Fetch data from database\nconst participants = await prisma.response.findMany({\n  where: { verified: true },\n  distinct: ['email'],\n});\n\n// 2. Sync to Listmonk\nconst results = { created: 0, updated: 0, failed: 0 };\nfor (const participant of participants) {\n  try {\n    const existing = await listmonkClient.getSubscriberByEmail(participant.email);\n    if (existing) {\n      await listmonkClient.updateSubscriber(existing.id, { /* ... */ });\n      results.updated++;\n    } else {\n      await listmonkClient.createSubscriber({ /* ... */ });\n      results.created++;\n    }\n  } catch (error) {\n    results.failed++;\n  }\n}\n\n// 3. Update last sync timestamp\nawait prisma.listmonkStatus.update({\n  where: { id: 'singleton' },\n  data: { lastSyncAt: new Date() },\n});\n\nreturn { success: true, message: `Synced participants: ${results.created} created, ${results.updated} updated`, results };\n
"},{"location":"v2/frontend/pages/admin/listmonk-page/#sync-all-lists","title":"Sync All Lists","text":"

Request:

const { data } = await api.post<ListmonkSyncAllResult>('/listmonk/sync/all');\n

Response (200 OK):

{\n  \"success\": true,\n  \"message\": \"Synced all lists: 347 participants, 203 locations, 15 users\",\n  \"results\": {\n    \"participants\": {\n      \"created\": 347,\n      \"updated\": 23,\n      \"failed\": 5\n    },\n    \"locations\": {\n      \"created\": 203,\n      \"updated\": 12,\n      \"failed\": 2\n    },\n    \"users\": {\n      \"created\": 15,\n      \"updated\": 3,\n      \"failed\": 1\n    }\n  }\n}\n

Response Fields: - success (boolean): Whether all syncs completed - message (string): Result summary - results (object): - participants (object): Participant sync results - locations (object): Location sync results - users (object): User sync results

"},{"location":"v2/frontend/pages/admin/listmonk-page/#reinitialize-lists","title":"Reinitialize Lists","text":"

Request:

await api.post('/listmonk/reinitialize');\n

Response (200 OK):

{\n  \"message\": \"Lists reinitialized\"\n}\n

Backend Workflow:

// 1. Check for existence of each list\nconst lists = await listmonkClient.getLists();\nconst participantsList = lists.find(l => l.name === 'Participants');\nconst locationsList = lists.find(l => l.name === 'Locations');\nconst usersList = lists.find(l => l.name === 'Users');\n\n// 2. Create missing lists\nif (!participantsList) {\n  await listmonkClient.createList({\n    name: 'Participants',\n    type: 'public',\n    description: 'Campaign participants from response wall',\n  });\n}\n\nif (!locationsList) {\n  await listmonkClient.createList({\n    name: 'Locations',\n    type: 'public',\n    description: 'Map locations with email addresses',\n  });\n}\n\nif (!usersList) {\n  await listmonkClient.createList({\n    name: 'Users',\n    type: 'private',\n    description: 'User accounts (admins, volunteers)',\n  });\n}\n\n// 3. Update initialization status\nawait prisma.listmonkStatus.update({\n  where: { id: 'singleton' },\n  data: { initialized: true },\n});\n\nreturn { message: 'Lists reinitialized' };\n
"},{"location":"v2/frontend/pages/admin/listmonk-page/#get-iframe-proxy-url","title":"Get Iframe Proxy URL","text":"

Request:

const { data } = await api.get<{ port: number; token: string }>('/listmonk/proxy-url');\n

Response (200 OK):

{\n  \"port\": 9001,\n  \"token\": \"auto-auth-token-abc123xyz\"\n}\n

Response Fields: - port (number): Listmonk service port (typically 9001) - token (string): Auto-authentication token (valid for 5 minutes)

Backend Workflow:

// 1. Generate auto-authentication token\nconst token = crypto.randomBytes(32).toString('hex');\n\n// 2. Store token in Redis with 5-minute expiry\nawait redis.set(`listmonk-auth:${token}`, 'admin', 'EX', 300);\n\n// 3. Return port and token\nreturn {\n  port: process.env.LISTMONK_PORT || 9001,\n  token,\n};\n

Listmonk Auto-Authentication:

Listmonk checks Redis for token when /auth?token=xyz is accessed:

// Listmonk auth handler\nrouter.get('/auth', async (req, res) => {\n  const { token } = req.query;\n\n  // Verify token in Redis\n  const userId = await redis.get(`listmonk-auth:${token}`);\n\n  if (userId) {\n    // Auto-login user\n    req.session.userId = userId;\n    res.redirect('/admin');\n  } else {\n    res.status(401).send('Invalid or expired token');\n  }\n});\n
"},{"location":"v2/frontend/pages/admin/listmonk-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#complete-sync-flow","title":"Complete Sync Flow","text":"
const handleSync = async (type: 'participants' | 'locations' | 'users') => {\n  setSyncing(s => ({ ...s, [type]: true }));  // Set loading state for specific button\n  try {\n    const res = await api.post<ListmonkSyncResult>(`/listmonk/sync/${type}`);\n\n    // Show success or warning message\n    if (res.data.success) {\n      message.success(res.data.message);\n\n      // Show warning if some failed\n      if (res.data.results && res.data.results.failed > 0) {\n        message.warning(`${res.data.results.failed} failed \u2014 check logs for details`);\n      }\n    } else {\n      message.error(res.data.message);\n    }\n\n    // Refresh status and stats\n    await Promise.all([fetchStatus(), fetchStats()]);\n  } catch {\n    message.error(`Failed to sync ${type}`);\n  } finally {\n    setSyncing(s => ({ ...s, [type]: false }));  // Clear loading state\n  }\n};\n

Key Steps: 1. Set loading state for specific button (participants, locations, or users) 2. Send POST request to sync endpoint 3. Show success message 4. Show warning if some subscribers failed to sync 5. Refresh status and stats to show updated counts 6. Handle errors gracefully 7. Always clear loading state in finally block

"},{"location":"v2/frontend/pages/admin/listmonk-page/#sync-all-flow","title":"Sync All Flow","text":"
const handleSyncAll = async () => {\n  setSyncing(s => ({ ...s, all: true }));\n  try {\n    const res = await api.post<ListmonkSyncAllResult>('/listmonk/sync/all');\n\n    if (res.data.success) {\n      message.success(res.data.message);\n\n      // Show warning if any failed\n      if (res.data.results) {\n        const { participants, locations, users } = res.data.results;\n        const totalFailed = participants.failed + locations.failed + users.failed;\n        if (totalFailed > 0) {\n          message.warning(`${totalFailed} total failures \u2014 check logs for details`);\n        }\n      }\n    } else {\n      message.error(res.data.message);\n    }\n\n    await Promise.all([fetchStatus(), fetchStats()]);\n  } catch {\n    message.error('Failed to sync all');\n  } finally {\n    setSyncing(s => ({ ...s, all: false }));\n  }\n};\n

Aggregate Failure Count:

Sums failed count from all three lists (participants + locations + users) to show total failures.

"},{"location":"v2/frontend/pages/admin/listmonk-page/#lazy-iframe-loading_1","title":"Lazy Iframe Loading","text":"
const iframeInitialized = useRef(false);\n\nconst loadIframe = useCallback(async () => {\n  if (iframeInitialized.current && iframeSrc) return;  // Already loaded\n\n  setIframeLoading(true);\n  setIframeError(null);\n  try {\n    const res = await api.get<{ port: number; token: string }>('/listmonk/proxy-url');\n    const { port, token } = res.data;\n    const url = `//${window.location.hostname}:${port}/auth?token=${encodeURIComponent(token)}`;\n    setIframeSrc(url);\n    iframeInitialized.current = true;\n  } catch {\n    setIframeError('Failed to load Listmonk admin \u2014 ensure the proxy is running');\n  } finally {\n    setIframeLoading(false);\n  }\n}, [iframeSrc]);\n

Lazy Loading Logic:

"},{"location":"v2/frontend/pages/admin/listmonk-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#lazy-iframe-loading_2","title":"Lazy Iframe Loading","text":"

Iframe only loads when Admin tab is selected:

if (tab === 'admin') loadIframe();\n

Performance Impact: - Without lazy loading: Iframe loads on page mount (even if user never switches to Admin tab) - With lazy loading: Iframe only loads if needed - Result: Faster initial page load, reduced memory usage

"},{"location":"v2/frontend/pages/admin/listmonk-page/#parallel-status-and-stats-fetching","title":"Parallel Status and Stats Fetching","text":"

Status and stats fetched in parallel:

const fetchAll = useCallback(async () => {\n  setLoading(true);\n  await Promise.all([fetchStatus(), fetchStats()]);\n  setLoading(false);\n}, [fetchStatus, fetchStats]);\n

Performance Impact: - Sequential: 200ms (status) + 200ms (stats) = 400ms total - Parallel: max(200ms, 200ms) = 200ms total - Result: 2\u00d7 faster initial page load

"},{"location":"v2/frontend/pages/admin/listmonk-page/#conditional-iframe-rendering","title":"Conditional Iframe Rendering","text":"

Iframe not rendered until tab is selected:

{activeTab === 'admin' && (\n  <iframe src={iframeSrc} />\n)}\n

Performance Impact: - Always rendered: Iframe exists in DOM even when hidden (consumes memory) - Conditional: Iframe only exists in DOM when visible (no memory overhead)

"},{"location":"v2/frontend/pages/admin/listmonk-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#mobile-sync-actions-layout","title":"Mobile Sync Actions Layout","text":"

Sync action buttons adapt to mobile viewports:

<Row gutter={[8, 8]}>\n  <Col xs={24} sm={12}>  {/* Full width mobile, half width desktop */}\n    <Button block>Sync Participants</Button>\n  </Col>\n  <Col xs={24} sm={12}>\n    <Button block>Sync Locations</Button>\n  </Col>\n  <Col xs={24} sm={12}>\n    <Button block>Sync Users</Button>\n  </Col>\n  <Col xs={24} sm={12}>\n    <Button block>Sync All</Button>\n  </Col>\n</Row>\n

Responsive Grid: - Mobile (xs, <576px): Stacked buttons (full width) - Desktop (sm+, \u2265576px): 2\u00d72 grid (half width each)

"},{"location":"v2/frontend/pages/admin/listmonk-page/#iframe-height","title":"Iframe Height","text":"

Iframe fills available viewport height:

<iframe\n  src={iframeSrc}\n  style={{\n    height: 'calc(100vh - 64px)',  // Full viewport height minus header\n  }}\n/>\n

Calculation: - 100vh: Full viewport height - -64px: Subtract header height (64px) - Result: Iframe fills entire content area below header

"},{"location":"v2/frontend/pages/admin/listmonk-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#keyboard-navigation","title":"Keyboard Navigation","text":"

All interactive elements are keyboard-accessible:

Tab Switcher: - Tab: Focus on tab switcher - Arrow Keys: Navigate between Management and Admin tabs - Enter/Space: Activate selected tab

Buttons: - Tab: Move between buttons (Test Connection \u2192 Sync Participants \u2192 Sync Locations...) - Enter/Space: Activate focused button

Iframe: - Tab: Focus moves into iframe (Listmonk UI is keyboard-accessible) - Shift+Tab: Focus moves out of iframe back to page controls

"},{"location":"v2/frontend/pages/admin/listmonk-page/#screen-reader-support","title":"Screen Reader Support","text":"

All elements have proper ARIA labels:

Status Badges:

<Badge\n  status={status?.connected ? 'success' : 'warning'}\n  text={status?.connected ? 'Connected' : 'Disconnected'}\n  aria-label={`Listmonk connection status: ${status?.connected ? 'connected' : 'disconnected'}`}\n/>\n

Sync Buttons:

<Button\n  icon={<SyncOutlined />}\n  onClick={() => handleSync('participants')}\n  aria-label=\"Sync participants to Listmonk Participants list\"\n>\n  Sync Participants\n</Button>\n

"},{"location":"v2/frontend/pages/admin/listmonk-page/#color-contrast","title":"Color Contrast","text":"

All color-coded elements meet WCAG AA standards:

Status Badges: - Success (green dot): #52c41a = visible on all backgrounds - Warning (orange dot): #faad14 = visible on all backgrounds - Error (red dot): #ff4d4f = visible on all backgrounds - Default (gray dot): #d9d9d9 = visible on all backgrounds

"},{"location":"v2/frontend/pages/admin/listmonk-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#sync-disabled-buttons-grayed-out","title":"Sync Disabled (Buttons Grayed Out)","text":"

Problem: All sync buttons are grayed out (disabled state).

Diagnosis:

Check .env file:

grep LISTMONK_SYNC_ENABLED .env\n

Expected: LISTMONK_SYNC_ENABLED=true

Actual: LISTMONK_SYNC_ENABLED=false or missing

Solution:

  1. Edit .env file:

    nano .env\n

  2. Add or update line:

    LISTMONK_SYNC_ENABLED=true\n

  3. Restart API container:

    docker compose restart api\n

  4. Refresh page to see enabled buttons

"},{"location":"v2/frontend/pages/admin/listmonk-page/#connection-test-fails","title":"Connection Test Fails","text":"

Problem: Click \"Test Connection\", get error: \"Connection failed - check Listmonk URL and credentials\".

Diagnosis:

Check Listmonk container:

docker compose ps listmonk\n

Expected: STATUS = Up

Check Listmonk logs:

docker compose logs listmonk\n

Common errors:

ERROR: Database connection failed\nERROR: Authentication failed for user \"api\"\n

Possible Causes:

  1. Listmonk container down:
  2. Service not running
  3. Failed to start due to configuration error

  4. Wrong credentials:

  5. LISTMONK_ADMIN_USER or LISTMONK_ADMIN_PASSWORD incorrect
  6. API user not created in Listmonk database

  7. Network issue:

  8. API container cannot reach Listmonk container
  9. Docker network misconfigured

Solution:

  1. Start Listmonk:

    docker compose up -d listmonk\n

  2. Verify credentials:

    grep LISTMONK_ .env\n

Check that LISTMONK_ADMIN_USER and LISTMONK_ADMIN_PASSWORD match Listmonk configuration.

  1. Test connection manually:
    curl -u admin:password http://localhost:9001/api/health\n

Expected: {\"version\":\"2.3.0\"}

"},{"location":"v2/frontend/pages/admin/listmonk-page/#lists-not-initialized","title":"Lists Not Initialized","text":"

Problem: Status shows \"Lists Initialized: No\".

Diagnosis:

Check Listmonk lists:

docker compose exec listmonk listmonk --dump-all-lists\n

Expected: Participants, Locations, Users lists present

Actual: No lists found

Solution:

  1. Click \"Advanced\" to expand advanced section
  2. Click \"Reinitialize Lists\" button
  3. Confirm reinitialize
  4. Wait for success message: \"Lists reinitialized\"
  5. Refresh page to see \"Lists Initialized: Yes\"
"},{"location":"v2/frontend/pages/admin/listmonk-page/#iframe-not-loading","title":"Iframe Not Loading","text":"

Problem: Click \"Listmonk Admin\" tab, but only see loading spinner or error message.

Diagnosis:

Check iframe error message in Alert:

Failed to load Listmonk admin \u2014 ensure the proxy is running\n

Check browser console for errors:

Refused to display 'http://localhost:9001' in a frame because it set 'X-Frame-Options' to 'SAMEORIGIN'\n

Possible Causes:

  1. Proxy URL endpoint failed:
  2. API cannot generate auto-auth token
  3. Redis down (tokens stored in Redis)

  4. X-Frame-Options blocking:

  5. Listmonk sets X-Frame-Options: SAMEORIGIN
  6. Browser blocks iframe from different origin

  7. CORS issue:

  8. Listmonk does not allow iframe embedding from admin domain

Solution:

  1. Check Redis:
    docker compose ps redis\ndocker compose exec redis redis-cli PING\n

Expected: \"PONG\"

  1. Use \"Open Listmonk\" button instead:
  2. Opens Listmonk in new tab (no iframe, no X-Frame-Options issue)
  3. Manual login required (no auto-auth token)

  4. Configure Listmonk to allow iframes (developer fix):

  5. Edit Listmonk nginx config
  6. Remove or modify X-Frame-Options header
  7. Restart Listmonk container
"},{"location":"v2/frontend/pages/admin/listmonk-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/locations-page/","title":"LocationsPage","text":""},{"location":"v2/frontend/pages/admin/locations-page/#overview","title":"Overview","text":"

The LocationsPage is the centerpiece of the Map module, providing comprehensive location database management with dual-tab view (table + interactive map), multi-format CSV import (standard, NAR upload, NAR server), multi-provider geocoding, bulk operations, location history tracking, and expandable address units for multi-unit buildings. This is the most feature-rich CRUD page in the admin interface, handling millions of location records for canvassing operations.

Route: /app/map/locations Component: admin/src/pages/LocationsPage.tsx (1960 lines) Auth Required: Yes (SUPER_ADMIN, MAP_ADMIN roles) Layout: AppLayout

"},{"location":"v2/frontend/pages/admin/locations-page/#screenshot","title":"Screenshot","text":"

[Screenshot: Locations page with two rows of statistics cards at top (Total, Single Family, Multi-Unit, Mixed Use, Commercial, Geocoded percentage in first row; High/Medium/Low/Manual confidence levels + Avg Confidence in second row). Below stats are two tabs: \"Table\" (active) and \"Map\". Table tab shows search bar + confidence filter dropdown + \"Delete Selected\" button (when rows selected). Main table has columns: Address, Building Type (tags), Total Units, Coordinates, Geocode (confidence tags with provider), Created, Actions (edit + delete). Page header has 6 action buttons: Settings, Export CSV, Import CSV, Geocode Missing, Bulk Re-Geocode, Add Location.]

"},{"location":"v2/frontend/pages/admin/locations-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/locations-page/#core-features","title":"Core Features","text":""},{"location":"v2/frontend/pages/admin/locations-page/#geocoding-features","title":"Geocoding Features","text":""},{"location":"v2/frontend/pages/admin/locations-page/#import-features","title":"Import Features","text":""},{"location":"v2/frontend/pages/admin/locations-page/#map-features-map-tab","title":"Map Features (Map Tab)","text":""},{"location":"v2/frontend/pages/admin/locations-page/#statistics-dashboard","title":"Statistics Dashboard","text":""},{"location":"v2/frontend/pages/admin/locations-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/locations-page/#viewing-locations-table-tab","title":"Viewing Locations (Table Tab)","text":"
  1. Navigate to /app/map/locations
  2. Page loads with statistics cards at top:
  3. First row: Total count, building type breakdowns, geocode percentage
  4. Second row: Confidence distribution (high/medium/low/none), average confidence
  5. Table tab active by default (20 locations per page)
  6. View location details:
  7. Address (bold)
  8. Building Type tag (color-coded)
  9. Total Units (1 for single-family, 2+ for multi-unit)
  10. Coordinates (lat, lng to 5 decimals)
  11. Geocode confidence (tag + provider name)
  12. Created date
  13. Actions (edit, delete)
  14. Expand row (click anywhere) if Total Units > 1:
  15. Shows Address units table (apartments)
  16. Columns: Unit, Name, Contact, Building Type, Notes
  17. Use pagination at bottom (10/20/50/100 per page)
"},{"location":"v2/frontend/pages/admin/locations-page/#searching-and-filtering","title":"Searching and Filtering","text":"
  1. Search bar (top left):
  2. Type address or postal code
  3. 300ms debounce (waits for typing pause)
  4. Search resets pagination to page 1
  5. Confidence filter (top right):
  6. Select High, Medium, Low, or Manual/None
  7. Filter resets pagination to page 1
  8. Clear to show all locations
  9. Filters persist during pagination
"},{"location":"v2/frontend/pages/admin/locations-page/#creating-a-location-manually","title":"Creating a Location Manually","text":"
  1. Click \"Add Location\" button in page header
  2. Modal opens (600px width) with vertical form
  3. Fill required fields:
  4. Street Address (base building address, no unit number)
  5. Latitude (decimal degrees, 5 decimals, e.g. 45.42153)
  6. Longitude (decimal degrees, 5 decimals, e.g. -75.69719)
  7. Select Building Type (radio buttons, default: SINGLE_FAMILY):
  8. Single Family
  9. Multi-Unit
  10. Mixed Use
  11. Commercial
  12. Add Building Notes (optional):
  13. Access codes, manager contact, buzzer instructions
  14. Example: \"Access code: 1234, Ring buzzer for manager\"
  15. Click \"Create\" button
  16. Success message: \"Location created\"
  17. Modal closes, table refreshes to page 1, stats refresh
  18. If Map tab open, new marker appears
"},{"location":"v2/frontend/pages/admin/locations-page/#using-inline-geocoding","title":"Using Inline Geocoding","text":"
  1. In create/edit form, type address in Street Address field
  2. Click \"Geocode\" button (AimOutlined icon in input addonAfter)
  3. Button shows loading spinner
  4. API calls multi-provider geocoding service
  5. On success:
  6. Latitude and Longitude fields auto-fill
  7. Success message: \"Geocoded (Google, 95% confidence)\"
  8. Provider name + confidence shown in message
  9. On failure:
  10. Error message: \"Could not geocode address\"
  11. Manually enter coordinates or try different address
"},{"location":"v2/frontend/pages/admin/locations-page/#geocoding-missing-locations","title":"Geocoding Missing Locations","text":"
  1. Click \"Geocode Missing\" button in page header
  2. Button shows loading state
  3. API geocodes all locations without coordinates (latitude = null OR longitude = null)
  4. Success message: \"Geocoded 847 of 1250 locations (403 failed)\"
  5. Table refreshes, stats update
  6. Failed locations remain without coordinates (low-quality addresses)
"},{"location":"v2/frontend/pages/admin/locations-page/#bulk-re-geocoding-background-job","title":"Bulk Re-Geocoding (Background Job)","text":"
  1. Click \"Bulk Re-Geocode\" button in page header
  2. Modal opens (600px width) with form:
  3. Confidence Threshold (%): Only geocode below this (default: 60)
  4. Building Type Filter: Optionally filter by type
  5. Maximum Locations: Process up to N locations (default: 1000, max: 5000)
  6. Click \"Start Bulk Re-Geocode\" button
  7. Background job starts (BullMQ queue)
  8. Modal shows live progress:
  9. Progress bar (percentage complete)
  10. Current address being processed
  11. Stats: Processed X / Total, Improved, Unchanged, Failed
  12. Job completes:
  13. Final stats shown
  14. Success message: \"Bulk geocoding complete: 234 improved, 512 unchanged, 14 failed\"
  15. Click \"Close\" button
  16. Table refreshes, stats update
  17. Only locations with IMPROVED results are updated (unchanged locations left alone)
"},{"location":"v2/frontend/pages/admin/locations-page/#importing-standard-csv","title":"Importing Standard CSV","text":"
  1. Click \"Import CSV\" button in page header
  2. Modal opens (620px width) with 3 radio buttons:
  3. Standard CSV (selected by default)
  4. NAR Upload
  5. NAR Server
  6. Read format instructions:
  7. Columns: address, first name, last name, email, phone, unit number, support level (1-4), sign (yes/no), sign size, notes, latitude, longitude
  8. Column names matched flexibly (case-insensitive, ignores punctuation)
  9. Drag CSV file or click to upload
  10. File uploads, backend processes:
  11. Parses CSV rows
  12. Creates Location records (with addresses)
  13. Creates Address records (units) if unit number present
  14. Geocodes if lat/lng missing (optional)
  15. Success message: \"Imported 450 of 500 locations (30 warnings, 20 failed)\"
  16. If errors, warning modal shows error list (max 300px height, scrollable)
  17. Modal closes, table refreshes, stats update
"},{"location":"v2/frontend/pages/admin/locations-page/#importing-nar-upload-client-side","title":"Importing NAR Upload (Client-Side)","text":"
  1. Click \"Import CSV\" button
  2. Switch to \"NAR Upload\" radio button
  3. Read format instructions:
  4. Statistics Canada NAR Address CSV
  5. Supports 2025 format (CIVIC_NO, OFFICIAL_STREET_NAME, BG_X/BG_Y) and legacy format (STR_NBR, STR_NME, LAT/LNG)
  6. Auto-detects format
  7. Configure Geographic Filter dropdown:
  8. No filter \u2014 import all rows
  9. Map settings area \u2014 use configured center + zoom from Map Settings
  10. City name \u2014 enter city (e.g. \"Ottawa\", \"Edmonton\")
  11. Province / Territory \u2014 select from dropdown (13 provinces/territories)
  12. Cut boundary \u2014 select from cuts dropdown (only locations inside polygon)
  13. Toggle \"Residential only\" switch:
  14. ON (default): Skip commercial/industrial addresses
  15. OFF: Import all addresses
  16. Drag CSV file or click to upload (max 100MB)
  17. File uploads, backend processes:
  18. Parses NAR format (2025 or legacy)
  19. Joins Address + Location files if NAR 2025 format
  20. Converts BG_X/BG_Y (EPSG:3347 Lambert projection) to lat/lng using proj4
  21. Applies geographic filters (cut, city, province, map area)
  22. Deduplicates within 5m radius
  23. Batches 1000 rows at a time
  24. Progress indicator during import
  25. Results shown in modal:
  26. 6 statistics cards (Total Rows, Created, Duplicates, Out of Bounds, Invalid, Errors)
  27. Error list (if any)
  28. Success message: \"Created X of Y locations\"
  29. Table refreshes, stats update
"},{"location":"v2/frontend/pages/admin/locations-page/#importing-nar-server-server-side-streaming","title":"Importing NAR Server (Server-Side Streaming)","text":"
  1. Click \"Import CSV\" button
  2. Switch to \"NAR Server\" radio button
  3. Click \"Scan Server Directory\" button (first time only)
  4. Backend scans NAR_DATA_DIR (./data volume mount) for:
  5. Addresses/ directory with Address_{provinceCode}part.csv files
  6. Locations/ directory with Location_{provinceCode}.csv files
  7. Modal shows available provinces:
  8. Example: \"ON \u2014 Ontario (6 files, 2.3 GB)\"
  9. File count includes multi-part Address files
  10. Select province from dropdown
  11. Configure Geographic Filter:
  12. No filter \u2014 import all addresses
  13. City name \u2014 enter city
  14. Postal code prefix (FSA) \u2014 3 chars (e.g. K1A, E3B)
  15. Cut boundary \u2014 select from cuts dropdown
  16. Toggle \"Residential only\" switch (default: ON)
  17. Click \"Import {Province} Addresses\" button
  18. Backend starts streaming import:
  19. Live progress display (polls every 2 seconds):
  20. Import completes:
  21. Table refreshes, stats update

NAR Server vs Upload: - NAR Server: For multi-GB datasets (1M+ addresses), streams from server disk, no file upload, no size limit - NAR Upload: For smaller datasets (<100MB), client uploads file, faster for small imports

"},{"location":"v2/frontend/pages/admin/locations-page/#viewing-map-map-tab","title":"Viewing Map (Map Tab)","text":"
  1. Click \"Map\" tab (EnvironmentOutlined icon)
  2. Map loads with AdminMapView component
  3. Initial load fetches all locations (no bounds filter)
  4. Locations render as colored circle markers:
  5. Blue: Single Family
  6. Green: Multi-Unit
  7. Orange: Mixed Use
  8. Purple: Commercial
  9. Cut polygons overlay map (if any cuts exist)
  10. Floating controls on map:
  11. Add \u2014 Enter click-to-add mode
  12. Move \u2014 Enter drag-to-move mode
  13. GPS \u2014 Geolocate to current position
  14. Fullscreen \u2014 Toggle fullscreen mode
  15. Refresh \u2014 Reload locations in current view
  16. Cut toggles \u2014 Show/hide cut overlays
  17. Pan/zoom map \u2192 auto-refreshes after 800ms debounce
  18. Click marker \u2192 location detail popup (address, building type, edit button)
"},{"location":"v2/frontend/pages/admin/locations-page/#adding-location-from-map","title":"Adding Location from Map","text":"
  1. In Map tab, click \"Add\" control button
  2. Click-to-add mode activated (cursor changes)
  3. Click anywhere on map
  4. Backend reverse geocodes coordinates (Nominatim)
  5. Create modal opens with pre-filled values:
  6. Latitude (rounded to 5 decimals)
  7. Longitude (rounded to 5 decimals)
  8. Address (reverse geocoded, e.g. \"123 Main St, City\")
  9. Adjust values if needed
  10. Select building type
  11. Click \"Create\"
  12. New marker appears on map
  13. Table updates if viewing Table tab
"},{"location":"v2/frontend/pages/admin/locations-page/#moving-location-on-map","title":"Moving Location on Map","text":"
  1. In Map tab, click \"Move\" control button
  2. Drag-to-move mode activated
  3. Click and drag any marker to new position
  4. On release, coordinates update:
  5. PUT /api/map/locations/:id with new lat/lng
  6. Marker snaps to new position
  7. Success message: \"Location moved\"
  8. Table updates if viewing Table tab
"},{"location":"v2/frontend/pages/admin/locations-page/#editing-a-location","title":"Editing a Location","text":"
  1. From Table tab:
  2. Click Edit icon button (EditOutlined) in Actions column
  3. From Map tab:
  4. Click marker \u2192 popup \u2192 click Edit button
  5. Drawer opens on right side (700px width) with 2 tabs:
  6. Details tab (active by default)
  7. History tab (ClockCircleOutlined icon)
  8. Details tab shows edit form:
  9. Same fields as create form
  10. Pre-filled with current values
  11. Geocode button available
  12. Modify any fields
  13. Click \"Save\" button in drawer header
  14. Success message: \"Location updated\"
  15. Drawer closes, table refreshes, stats update
  16. Map refreshes if viewing Map tab
"},{"location":"v2/frontend/pages/admin/locations-page/#viewing-location-history","title":"Viewing Location History","text":"
  1. Open location in edit drawer
  2. Click \"History\" tab (ClockCircleOutlined icon)
  3. Table loads with location history:
  4. Columns: Action, Field, Change, User, When
  5. Action tags (color-coded):
  6. Field shows which field changed (e.g. address, latitude)
  7. Change shows old \u2192 new values (strikethrough old, bold new)
  8. User shows email or \"System\"
  9. When shows timestamp (MMM D, YYYY h:mm A)
  10. Pagination at bottom (20 per page)
  11. History sorted newest first (most recent at top)
"},{"location":"v2/frontend/pages/admin/locations-page/#bulk-deleting-locations","title":"Bulk Deleting Locations","text":"
  1. In Table tab, select checkbox for multiple rows
  2. \"Delete Selected (N)\" button appears above table
  3. Click button
  4. Popconfirm: \"Delete N locations?\"
  5. Click \"OK\"
  6. Success message: \"Deleted N locations\"
  7. Selection cleared, table refreshes, stats update
"},{"location":"v2/frontend/pages/admin/locations-page/#exporting-csv","title":"Exporting CSV","text":"
  1. Click \"Export CSV\" button in page header
  2. Browser downloads CSV file: locations-YYYY-MM-DD.csv
  3. File contains all locations (not just current page):
  4. Columns: id, address, latitude, longitude, buildingType, buildingNotes, postalCode, province, federalDistrict, buildingUse, geocodeProvider, geocodeConfidence, totalUnits, createdAt, updatedAt
  5. Open in Excel, Google Sheets, or text editor
  6. Use for backups, analysis, or importing to other systems
"},{"location":"v2/frontend/pages/admin/locations-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/locations-page/#ant-design-components-used","title":"Ant Design Components Used","text":""},{"location":"v2/frontend/pages/admin/locations-page/#table-columns","title":"Table Columns","text":"
const columns: ColumnsType<Location> = [\n  {\n    title: 'Address',\n    dataIndex: 'address',\n    render: (addr) => <span style={{ fontWeight: 500 }}>{addr || '--'}</span>,\n  },\n  {\n    title: 'Building Type',\n    dataIndex: 'buildingType',\n    render: (type: BuildingType) => (\n      <Tag color={BUILDING_TYPE_COLORS[type]}>\n        {BUILDING_TYPE_LABELS[type]}\n      </Tag>\n    ),\n    responsive: ['md'],\n  },\n  {\n    title: 'Total Units',\n    dataIndex: 'totalUnits',\n    align: 'center',\n    width: 120,\n    responsive: ['md'],\n  },\n  {\n    title: 'Coordinates',\n    render: (_, record) =>\n      record.latitude && record.longitude\n        ? `${Number(record.latitude).toFixed(5)}, ${Number(record.longitude).toFixed(5)}`\n        : '--',\n    responsive: ['lg'],\n  },\n  {\n    title: 'Geocode',\n    render: (_, record) => {\n      if (record.geocodeConfidence != null && record.geocodeConfidence > 0) {\n        const confidence = record.geocodeConfidence;\n        let color, icon, label;\n        if (confidence >= 85) {\n          color = 'success';\n          icon = <CheckCircleOutlined />;\n          label = `High (${confidence}%)`;\n        } else if (confidence >= 60) {\n          color = 'warning';\n          icon = <InfoCircleOutlined />;\n          label = `Medium (${confidence}%)`;\n        } else {\n          color = 'error';\n          icon = <WarningOutlined />;\n          label = `Low (${confidence}%)`;\n        }\n        return (\n          <Space direction=\"vertical\" size={0}>\n            <Tag color={color} icon={icon}>{label}</Tag>\n            {record.geocodeProvider && (\n              <Text type=\"secondary\" style={{ fontSize: 11 }}>\n                {record.geocodeProvider.toLowerCase()}\n              </Text>\n            )}\n          </Space>\n        );\n      }\n      if (record.latitude && record.longitude) {\n        return <Tag color=\"blue\">Manual</Tag>;\n      }\n      return <Tag>None</Tag>;\n    },\n    responsive: ['lg'],\n  },\n  {\n    title: 'Created',\n    dataIndex: 'createdAt',\n    render: (date) => dayjs(date).format('YYYY-MM-DD'),\n    responsive: ['xl'],\n  },\n  {\n    title: 'Actions',\n    width: 120,\n    render: (_, record) => (\n      <Space>\n        <Button type=\"link\" size=\"small\" icon={<EditOutlined />} onClick={() => openEdit(record)} />\n        <Popconfirm title=\"Delete this location?\" onConfirm={() => handleDelete(record.id)}>\n          <Button type=\"link\" size=\"small\" danger icon={<DeleteOutlined />} />\n        </Popconfirm>\n      </Space>\n    ),\n  },\n];\n

Key patterns: - responsive array controls column visibility on different screen sizes - render functions for custom content (tags, icons, formatted values) - align: 'center' for numeric columns - width prop for fixed-width columns

"},{"location":"v2/frontend/pages/admin/locations-page/#expandable-rows-address-units","title":"Expandable Rows (Address Units)","text":"
const expandedRowRender = (location: Location) => {\n  if (!location.addresses || location.addresses.length === 0) {\n    return (\n      <div style={{ padding: '12px 24px', textAlign: 'center' }}>\n        <Text type=\"secondary\">No units/addresses defined for this location yet.</Text>\n        <div style={{ marginTop: 8, fontSize: 12 }}>\n          <Text type=\"secondary\">Units can be added during canvassing or imported via NAR data.</Text>\n        </div>\n      </div>\n    );\n  }\n\n  const addressColumns: ColumnsType<Address> = [\n    { title: 'Unit', dataIndex: 'unitNumber', width: 100, render: (unit) => unit || '--' },\n    { title: 'Name', render: (_, addr) => [addr.firstName, addr.lastName].filter(Boolean).join(' ') || '--' },\n    { title: 'Contact', render: (_, addr) => [addr.email, addr.phone].filter(Boolean).join(' \u2022 ') || '--', responsive: ['md'] },\n    { title: 'Building Type', dataIndex: 'buildingType', render: (type) => <Tag color={colors[type]}>{labels[type]}</Tag> },\n    { title: 'Notes', dataIndex: 'notes', ellipsis: true, render: (notes) => notes || '--', responsive: ['lg'] },\n  ];\n\n  return (\n    <div style={{ padding: '0 24px 12px' }}>\n      <Table<Address>\n        columns={addressColumns}\n        dataSource={location.addresses}\n        rowKey=\"id\"\n        pagination={false}\n        size=\"small\"\n        bordered\n      />\n    </div>\n  );\n};\n\n// In main table:\n<Table\n  expandable={{\n    expandedRowRender,\n    rowExpandable: (record) => (record.totalUnits > 1 || (record.addresses && record.addresses.length > 0)),\n  }}\n/>\n

Pattern: Nested table shows Address units (apartments) for multi-unit buildings. Only expandable if totalUnits > 1 or addresses array exists.

"},{"location":"v2/frontend/pages/admin/locations-page/#statistics-cards","title":"Statistics Cards","text":"
{stats && (\n  <Row gutter={[12, 12]} style={{ marginBottom: 16 }}>\n    <Col xs={12} sm={8} md={4}>\n      <Card size=\"small\">\n        <Statistic title=\"Total\" value={stats.total} />\n      </Card>\n    </Col>\n    <Col xs={12} sm={8} md={4}>\n      <Card size=\"small\">\n        <Statistic title=\"Single Family\" value={stats.buildingTypes.SINGLE_FAMILY} valueStyle={{ color: '#1890ff' }} />\n      </Card>\n    </Col>\n    {/* 4 more building type cards */}\n  </Row>\n)}\n\n{stats && stats.confidence && (\n  <Row gutter={[12, 12]} style={{ marginBottom: 16 }}>\n    <Col xs={12} sm={6} md={4}>\n      <Card size=\"small\">\n        <Statistic\n          title=\"High Confidence\"\n          value={stats.confidence.high}\n          prefix={<CheckCircleOutlined />}\n          valueStyle={{ color: '#52c41a' }}\n          suffix={<Text type=\"secondary\" style={{ fontSize: 12 }}>\u226585%</Text>}\n        />\n      </Card>\n    </Col>\n    {/* 3 more confidence cards */}\n  </Row>\n)}\n

Layout: - First row: 6 cards (Total + 4 building types + Geocoded %) - Second row: 5 cards (High/Medium/Low/None confidence + Avg confidence) - Responsive: xs (2 columns), sm (3-4 columns), md+ (6 columns)

"},{"location":"v2/frontend/pages/admin/locations-page/#nar-format-detection","title":"NAR Format Detection","text":"

NAR import supports two formats: - 2025 format: CIVIC_NO, OFFICIAL_STREET_NAME, BG_X, BG_Y (Lambert projection EPSG:3347) - Legacy format: STR_NBR, STR_NME, LAT, LNG (decimal degrees)

Backend auto-detects format by checking for presence of CIVIC_NO column.

2025 format with proj4 conversion:

import proj4 from 'proj4';\n\n// Define Lambert Conformal Conic projection (Statistics Canada NAR)\nproj4.defs('EPSG:3347', '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=63.390675 +lon_0=-91.86666666666666 +x_0=6200000 +y_0=3000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs');\n\n// Convert BG_X, BG_Y (meters) to lat, lng (decimal degrees)\nconst [lng, lat] = proj4('EPSG:3347', 'EPSG:4326', [bgX, bgY]);\n

File join (2025 format only):

// Address file: CIVIC_NO, OFFICIAL_STREET_NAME, LOC_GUID (no coordinates)\n// Location file: LOC_GUID, BG_LATITUDE, BG_LONGITUDE (coordinates only)\n// Join on LOC_GUID to get address + coordinates\n
"},{"location":"v2/frontend/pages/admin/locations-page/#map-auto-refresh","title":"Map Auto-Refresh","text":"
const handleMapMove = useCallback((map: any) => {\n  // Skip if map is animating (prevents disrupting zoom transitions)\n  if (map._animatingZoom || map._moving) {\n    return;\n  }\n\n  const b = map.getBounds();\n  const newBounds = {\n    minLat: b.getSouth(),\n    maxLat: b.getNorth(),\n    minLng: b.getWest(),\n    maxLng: b.getEast(),\n  };\n\n  // Store current bounds for auto-refresh\n  currentBoundsRef.current = newBounds;\n\n  clearTimeout(fetchTimerRef.current);\n  fetchTimerRef.current = setTimeout(() => {\n    // Mark as background fetch to prevent loading state during viewport changes\n    fetchAllLocations(newBounds, true);\n  }, 800); // Increased debounce to 800ms to allow zoom animations to complete\n}, [fetchAllLocations]);\n

Why 800ms debounce? - Allows zoom/pan animations to complete - Prevents API spam during dragging - Only fetches when user pauses - Background fetch (no loading spinner) for smooth UX

Safety limit:

if (data.length === 5000) {\n  message.warning('Too many locations in view. Zoom in for more detail.', 3);\n}\n

Backend returns max 5000 locations per request to prevent memory issues.

"},{"location":"v2/frontend/pages/admin/locations-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/locations-page/#zustand-stores-used","title":"Zustand Stores Used","text":"

None \u2014 Locations fetched from API on each interaction. No global state required (unlike canvass or auth).

"},{"location":"v2/frontend/pages/admin/locations-page/#local-state","title":"Local State","text":"
const [locations, setLocations] = useState<Location[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });\nconst [loading, setLoading] = useState(false);\nconst [stats, setStats] = useState<LocationStats | null>(null);\nconst [search, setSearch] = useState('');\nconst [debouncedSearch, setDebouncedSearch] = useState('');\nconst searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\nconst [confidenceFilter, setConfidenceFilter] = useState<'high' | 'medium' | 'low' | 'none' | undefined>();\n\n// Modals/Drawers\nconst [createModalOpen, setCreateModalOpen] = useState(false);\nconst [editDrawerOpen, setEditDrawerOpen] = useState(false);\nconst [editingLocation, setEditingLocation] = useState<Location | null>(null);\nconst [locationHistory, setLocationHistory] = useState<LocationHistory[]>([]);\nconst [historyLoading, setHistoryLoading] = useState(false);\nconst [historyPagination, setHistoryPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });\nconst [importModalOpen, setImportModalOpen] = useState(false);\nconst [importing, setImporting] = useState(false);\nconst [geocodingMissing, setGeocodingMissing] = useState(false);\nconst [importFormat, setImportFormat] = useState<'standard' | 'nar' | 'server'>('standard');\nconst [bulkImportResult, setBulkImportResult] = useState<BulkImportResult | null>(null);\nconst [cuts, setCuts] = useState<Cut[]>([]);\n\n// NAR Server Import state\nconst [narDatasets, setNarDatasets] = useState<NarDataset[]>([]);\nconst [narDatasetsLoading, setNarDatasetsLoading] = useState(false);\nconst [narDir, setNarDir] = useState<string | null>(null);\nconst [narSelectedProvince, setNarSelectedProvince] = useState<string | undefined>();\nconst [narImportResult, setNarImportResult] = useState<NarServerImportResult | null>(null);\nconst [narProgress, setNarProgress] = useState<NarImportProgress | null>(null);\nconst narPollRef = useRef<ReturnType<typeof setInterval>>(undefined);\n\n// Bulk Re-Geocoding state\nconst [bulkGeocodeModalOpen, setBulkGeocodeModalOpen] = useState(false);\nconst [bulkGeocoding, setBulkGeocoding] = useState(false);\nconst [bulkGeocodeJobId, setBulkGeocodeJobId] = useState<string | null>(null);\nconst [bulkGeocodeStatus, setBulkGeocodeStatus] = useState<any>(null);\nconst bulkGeocodePollRef = useRef<ReturnType<typeof setInterval>>(undefined);\nconst [bulkGeocodeForm] = Form.useForm();\n\n// Tabs + map\nconst [activeTab, setActiveTab] = useState('table');\nconst [allLocations, setAllLocations] = useState<Location[]>([]);\nconst [mapLoading, setMapLoading] = useState(false);\nconst fetchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\nconst abortControllerRef = useRef<AbortController | null>(null);\nconst currentBoundsRef = useRef<{ minLat: number; maxLat: number; minLng: number; maxLng: number } | null>(null);\n\n// Selection for bulk ops\nconst [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);\n\nconst [createForm] = Form.useForm();\nconst [editForm] = Form.useForm();\nconst [geocoding, setGeocoding] = useState(false);\n

Complexity: 40+ state variables for comprehensive feature set.

"},{"location":"v2/frontend/pages/admin/locations-page/#polling-patterns","title":"Polling Patterns","text":"

NAR Server Import Polling:

// Start polling\nnarPollRef.current = setInterval(async () => {\n  try {\n    const { data: progress } = await api.get<NarImportProgress>(`/map/nar-import/status/${importId}`);\n    setNarProgress(progress);\n\n    if (progress.status === 'complete') {\n      stopNarPolling();\n      setImporting(false);\n      if (progress.result) {\n        setNarImportResult(progress.result);\n        message.success(`Imported ${progress.result.created} locations from ${progress.result.provinceName} in ${(progress.result.durationMs / 1000).toFixed(1)}s`);\n      }\n      fetchLocations({ page: 1 });\n      fetchStats();\n    } else if (progress.status === 'failed') {\n      stopNarPolling();\n      setImporting(false);\n      message.error(progress.error || 'NAR import failed');\n    }\n  } catch {\n    // Polling error \u2014 don't stop, might be transient\n  }\n}, 2000);  // Poll every 2 seconds\n\n// Cleanup on unmount\nuseEffect(() => {\n  return () => stopNarPolling();\n}, [stopNarPolling]);\n

Bulk Geocode Polling:

Similar pattern for bulk geocoding background job. Polls job status every 2 seconds until complete/failed.

"},{"location":"v2/frontend/pages/admin/locations-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/locations-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose GET /api/map/locations List locations (paginated, filtered) GET /api/map/locations/stats Fetch statistics (counts, confidence) GET /api/map/locations/all Fetch all locations (optionally bounds-filtered, max 5000) GET /api/map/locations/:id Fetch single location with addresses GET /api/map/locations/:id/history Fetch location history (paginated) POST /api/map/locations Create location PUT /api/map/locations/:id Update location DELETE /api/map/locations/:id Delete location POST /api/map/locations/bulk-delete Bulk delete locations POST /api/map/locations/geocode Geocode single address POST /api/map/locations/reverse-geocode Reverse geocode coordinates POST /api/map/locations/geocode-missing Batch geocode all missing POST /api/map/locations/bulk-geocode Start bulk re-geocode job GET /api/map/locations/bulk-geocode/:jobId Poll bulk geocode status GET /api/map/locations/export-csv Export all locations as CSV POST /api/map/locations/import-csv Import standard CSV POST /api/map/locations/import-bulk Import NAR CSV (client upload) GET /api/map/nar-import/datasets Scan server NAR directory POST /api/map/nar-import Start NAR server import GET /api/map/nar-import/status/:importId Poll NAR import progress"},{"location":"v2/frontend/pages/admin/locations-page/#list-locations","title":"List Locations","text":"

Request:

const { data } = await api.get<LocationsListResponse>('/map/locations', {\n  params: {\n    page: 1,\n    limit: 20,\n    search: '123 Main',           // Optional: search address or postal\n    confidenceLevel: 'high',      // Optional: high, medium, low, none\n  },\n});\n

Response:

{\n  \"locations\": [\n    {\n      \"id\": \"loc-123\",\n      \"address\": \"123 Main St, Ottawa, ON K1A 0A1\",\n      \"buildingType\": \"MULTI_UNIT\",\n      \"buildingNotes\": \"Access code: 1234\",\n      \"latitude\": \"45.42153\",\n      \"longitude\": \"-75.69719\",\n      \"postalCode\": \"K1A0A1\",\n      \"province\": \"ON\",\n      \"federalDistrict\": \"Ottawa\u2014Vanier\",\n      \"buildingUse\": \"RESIDENTIAL\",\n      \"geocodeProvider\": \"GOOGLE\",\n      \"geocodeConfidence\": 95,\n      \"geocodeAddress\": \"123 Main Street, Ottawa, Ontario K1A 0A1, Canada\",\n      \"totalUnits\": 12,\n      \"createdAt\": \"2026-01-15T10:00:00.000Z\",\n      \"updatedAt\": \"2026-01-20T14:30:00.000Z\",\n      \"addresses\": [\n        {\n          \"id\": \"addr-456\",\n          \"unitNumber\": \"101\",\n          \"firstName\": \"John\",\n          \"lastName\": \"Doe\",\n          \"email\": \"john@example.com\",\n          \"phone\": \"613-555-1234\",\n          \"buildingType\": \"MULTI_UNIT\",\n          \"notes\": \"Friendly, supports campaign\"\n        },\n        // ... 11 more units\n      ]\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 5847,\n    \"totalPages\": 293\n  }\n}\n

Key fields: - addresses \u2014 Nested array of Address units (apartments) - totalUnits \u2014 Count of units (1 for single-family, 2+ for multi-unit) - geocodeProvider \u2014 Provider name (GOOGLE, NOMINATIM, etc.) - geocodeConfidence \u2014 0-100% confidence score - postalCode, province, federalDistrict \u2014 NAR import fields

"},{"location":"v2/frontend/pages/admin/locations-page/#fetch-statistics","title":"Fetch Statistics","text":"

Request:

const { data } = await api.get<LocationStats>('/map/locations/stats');\n

Response:

{\n  \"total\": 5847,\n  \"buildingTypes\": {\n    \"SINGLE_FAMILY\": 4123,\n    \"MULTI_UNIT\": 1234,\n    \"MIXED_USE\": 345,\n    \"COMMERCIAL\": 145\n  },\n  \"geocoded\": 5421,\n  \"confidence\": {\n    \"high\": 4567,\n    \"medium\": 789,\n    \"low\": 65,\n    \"none\": 426,\n    \"average\": 87.3\n  }\n}\n
"},{"location":"v2/frontend/pages/admin/locations-page/#geocode-address","title":"Geocode Address","text":"

Request:

const { data } = await api.post<GeocodeResult>('/map/locations/geocode', {\n  address: '123 Main St, Ottawa, ON',\n});\n

Response:

{\n  \"latitude\": 45.42153,\n  \"longitude\": -75.69719,\n  \"provider\": \"GOOGLE\",\n  \"confidence\": 95,\n  \"geocodedAddress\": \"123 Main Street, Ottawa, Ontario K1A 0A1, Canada\"\n}\n
"},{"location":"v2/frontend/pages/admin/locations-page/#reverse-geocode","title":"Reverse Geocode","text":"

Request:

const { data } = await api.post<ReverseGeocodeResult>('/map/locations/reverse-geocode', {\n  latitude: 45.42153,\n  longitude: -75.69719,\n});\n

Response:

{\n  \"address\": \"123 Main St, Ottawa, ON K1A 0A1, Canada\",\n  \"provider\": \"NOMINATIM\"\n}\n
"},{"location":"v2/frontend/pages/admin/locations-page/#nar-server-import","title":"NAR Server Import","text":"

Request:

const { data } = await api.post<{ importId: string }>('/map/nar-import', {\n  provinceCode: '24',\n  filterType: 'city',\n  filterCity: 'Montreal',\n  residentialOnly: true,\n  deduplicateRadius: 5,\n  batchSize: 1000,\n});\n

Response:

{\n  \"importId\": \"import-789\"\n}\n

Poll status:

const { data } = await api.get<NarImportProgress>(`/map/nar-import/status/${importId}`);\n

Progress response:

{\n  \"importId\": \"import-789\",\n  \"status\": \"processing\",\n  \"currentFile\": \"Address_24_part_3.csv\",\n  \"totalRows\": 45678,\n  \"locationsCreated\": 12345,\n  \"skippedDuplicate\": 234,\n  \"skippedOutOfBounds\": 156,\n  \"skippedNonResidential\": 1234,\n  \"skippedInvalid\": 45\n}\n

Complete response:

{\n  \"importId\": \"import-789\",\n  \"status\": \"complete\",\n  \"result\": {\n    \"provinceName\": \"Quebec\",\n    \"totalRows\": 67890,\n    \"created\": 54321,\n    \"skippedDuplicate\": 456,\n    \"skippedOutOfBounds\": 234,\n    \"skippedNonResidential\": 12345,\n    \"skippedInvalid\": 78,\n    \"durationMs\": 43200,\n    \"errors\": []\n  }\n}\n
"},{"location":"v2/frontend/pages/admin/locations-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/locations-page/#geocode-confidence-always-low","title":"Geocode Confidence Always Low","text":"

Problem: All geocoded locations show Low (<60%) confidence tags.

Diagnosis:

Check geocoding provider priority in backend:

// api/src/modules/map/geocoding/geocoding.service.ts\nconst providers = ['GOOGLE', 'NOMINATIM', 'ARCGIS', 'PHOTON', 'MAPBOX', 'PELIAS'];\n

Common Issues:

  1. Google API key missing/invalid:
  2. Check .env: GOOGLE_GEOCODING_API_KEY=your-key-here
  3. Verify key has Geocoding API enabled
  4. Check quota limits

  5. Poor address quality:

  6. Addresses missing street number, city, or postal code
  7. Example: \"Main St\" (missing number + city) \u2192 low confidence
  8. Solution: Clean address data before import

  9. Provider fallback chain:

  10. Google fails \u2192 tries Nominatim (lower confidence)
  11. Nominatim fails \u2192 tries ArcGIS, etc.
  12. Solution: Fix primary provider (Google)

Solution:

Run bulk re-geocode with confidence threshold 60: 1. Click \"Bulk Re-Geocode\" button 2. Set threshold to 60 3. Start job 4. Improved locations update with higher confidence

"},{"location":"v2/frontend/pages/admin/locations-page/#nar-server-import-not-finding-files","title":"NAR Server Import Not Finding Files","text":"

Problem: Click \"Scan Server Directory\" \u2192 \"No NAR datasets found in /data\"

Diagnosis:

Check docker-compose volume mount:

volumes:\n  - ./data:/data:ro\n

Common Issues:

  1. Data directory doesn't exist:

    mkdir -p ./data\n

  2. NAR files not extracted:

  3. Download NAR zip from Statistics Canada
  4. Extract to ./data/ directory
  5. Ensure Addresses/ and Locations/ subdirectories exist

  6. Wrong directory structure:

    # Wrong (zip extracted to subdirectory):\n./data/NAR_2025/Addresses/Address_24.csv\n\n# Correct (direct in ./data):\n./data/Addresses/Address_24.csv\n./data/Locations/Location_24.csv\n

  7. File permissions:

    chmod -R 755 ./data\n

Solution:

cd changemaker-lite\nmkdir -p ./data\ncd ./data\n# Download NAR zip from Statistics Canada\nunzip NAR_2025.zip\n# Move Addresses/ and Locations/ to ./data root\nmv NAR_2025/Addresses .\nmv NAR_2025/Locations .\n# Restart API container\ndocker compose restart api\n
"},{"location":"v2/frontend/pages/admin/locations-page/#map-shows-too-many-locations-in-view","title":"Map Shows \"Too Many Locations in View\"","text":"

Problem: Zoom out on map \u2192 Warning: \"Too many locations in view. Zoom in for more detail.\"

Diagnosis:

Backend safety limit triggered:

// Max 5000 locations per request\nif (locations.length >= 5000) {\n  return res.json(locations.slice(0, 5000));\n}\n

Not an error: Protection against loading millions of markers.

Solution:

  1. Zoom in to reduce visible area
  2. Map auto-refreshes with smaller bounds
  3. Fewer locations load (no warning)

Alternative: Use Table tab + search/filters to find specific locations.

"},{"location":"v2/frontend/pages/admin/locations-page/#bulk-re-geocode-stuck-at-99","title":"Bulk Re-Geocode Stuck at 99%","text":"

Problem: Start bulk re-geocode \u2192 progress bar reaches 99% \u2192 never completes.

Diagnosis:

Check BullMQ queue health:

docker compose logs -f api\n# Look for: \"Bulk geocode job failed: ETIMEDOUT\"\n

Common Issues:

  1. Geocoding provider timeout:
  2. Google API rate limit exceeded (50 req/sec)
  3. Solution: Reduce job concurrency in backend

  4. Redis connection lost:

  5. Check redis container: docker compose ps redis
  6. Solution: Restart redis: docker compose restart redis

  7. Job worker crashed:

  8. Check API logs for errors
  9. Solution: Restart API: docker compose restart api

Solution:

Cancel stuck job: 1. Close bulk geocode modal 2. Restart API container: docker compose restart api 3. Retry bulk geocode with smaller limit (e.g., 500 instead of 1000)

"},{"location":"v2/frontend/pages/admin/locations-page/#csv-import-shows-invalid-errors","title":"CSV Import Shows \"Invalid\" Errors","text":"

Problem: Import CSV \u2192 Result: \"450 created, 50 invalid\"

Diagnosis:

Check error list in import modal: - Row 23: \"Missing required field: address\" - Row 45: \"Invalid coordinates: latitude > 90\" - Row 67: \"Address too long (max 500 chars)\"

Common Issues:

  1. Missing required columns:
  2. Standard CSV: address required, lat/lng optional
  3. NAR CSV: Address file requires CIVIC_NO + OFFICIAL_STREET_NAME (2025) or STR_NBR + STR_NME (legacy)

  4. Invalid coordinates:

  5. Latitude out of range (-90 to 90)
  6. Longitude out of range (-180 to 180)
  7. Non-numeric values in lat/lng columns

  8. Encoding issues:

  9. CSV not UTF-8 encoded
  10. Solution: Re-save CSV as UTF-8 in Excel/LibreOffice

Solution:

  1. Export failed rows to new CSV for fixing
  2. Clean data in spreadsheet:
  3. Fill missing addresses
  4. Fix coordinate ranges
  5. Remove invalid characters
  6. Re-import cleaned CSV
"},{"location":"v2/frontend/pages/admin/locations-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/","title":"MailHogPage","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#overview","title":"Overview","text":"

File: admin/src/pages/MailHogPage.tsx

Route: /app/services/mailhog

Role Requirements: Any authenticated user (uses authenticate middleware)

Purpose: Provides an embedded interface to the MailHog email testing service via iframe. MailHog is a development email capture tool that intercepts SMTP emails and displays them in a web interface, allowing developers to test email functionality without sending real emails. This page serves as a wrapper that embeds MailHog with online/offline status monitoring and mobile device detection.

Key Features: - Full-page iframe embed of MailHog service - Service online/offline status monitoring with Badge - Mobile device detection with warning screen - \"Refresh\" button to re-check service status - \"Open in New Tab\" button for external access - Fullbleed layout (no padding in AppLayout) - Automatic service health checks via API

Layout: AppLayout with fullbleed (no content padding)

Dependencies: - Ant Design v5 (Button, Space, Badge, Spin, Grid, Result) - react-router-dom (useOutletContext)

"},{"location":"v2/frontend/pages/admin/mailhog-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#1-service-status-monitoring","title":"1. Service Status Monitoring","text":"

Status Display: - Green \"Online\" badge when MailHog is accessible - Red \"Offline\" badge when MailHog is not accessible - Blue \"Checking...\" badge during status check - Badge displayed in page header

Status Checks: - Initial check on page load - Manual check via \"Refresh\" button - No automatic periodic refresh

"},{"location":"v2/frontend/pages/admin/mailhog-page/#2-mobile-device-detection","title":"2. Mobile Device Detection","text":"

Mobile Warning Screen: - Detects mobile devices using Grid.useBreakpoint() - Shows warning Result component on mobile - Recommends using desktop for better email viewing experience - Icon: MailOutlined (48px)

Breakpoint: !screens.md (screen width < 768px = mobile)

"},{"location":"v2/frontend/pages/admin/mailhog-page/#3-service-url-building","title":"3. Service URL Building","text":"

URL Construction: - Fetches service config from API (/api/services/config) - Builds URL using buildServiceUrl() helper - Uses subdomain + domain + port configuration - Example: http://mailhog.cmlite.org or http://localhost:8025

"},{"location":"v2/frontend/pages/admin/mailhog-page/#4-iframe-embedding","title":"4. Iframe Embedding","text":"

Fullbleed Layout: - No padding around iframe - Height: calc(100vh - 64px) (full viewport height minus header) - Width: 100% - No border for seamless integration

Error Handling: - Shows error Result if service offline - Provides \"Retry\" button to re-check status - Clear error messaging

"},{"location":"v2/frontend/pages/admin/mailhog-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#accessing-mailhog-service","title":"Accessing MailHog Service","text":"
  1. Navigate to MailHog:
  2. Click \"Services\" \u2192 \"MailHog\" in sidebar
  3. Page loads with status check

  4. Check Service Status:

  5. Status badge appears in page header:

  6. View on Desktop:

  7. If on desktop (screen width \u2265 768px):

  8. View on Mobile:

  9. If on mobile (screen width < 768px):

  10. Using MailHog Service:

  11. Inbox View: See all captured emails
  12. Email Preview: Click email to view full content (HTML + text)
  13. Search: Filter emails by sender, recipient, subject
  14. Delete: Delete individual emails or clear all
  15. Raw View: View raw email source (headers + body)

  16. Troubleshoot Offline Service:

  17. If service shows \"Offline\":
  18. Refresh page after fixing
"},{"location":"v2/frontend/pages/admin/mailhog-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#main-component-structure","title":"Main Component Structure","text":"
export default function MailHogPage() {\n  const { setPageHeader } = useOutletContext<AppOutletContext>();\n  const screens = Grid.useBreakpoint();\n  const isMobile = !screens.md;\n\n  const [online, setOnline] = useState<boolean | null>(null);\n  const [config, setConfig] = useState<ServicesConfig | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  // Fetch service status and config\n  const fetchStatus = useCallback(async () => {\n    try {\n      const [statusRes, configRes] = await Promise.all([\n        api.get<ServicesStatus>('/services/status'),\n        api.get<ServicesConfig>('/services/config'),\n      ]);\n      setOnline(statusRes.data.mailhog.online);\n      setConfig(configRes.data);\n    } catch {\n      setOnline(false);\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    fetchStatus();\n  }, [fetchStatus]);\n\n  // Build service URL\n  const serviceUrl = config\n    ? buildServiceUrl(config.mailhogSubdomain, config.domain, config.mailhogPort)\n    : null;\n\n  // Page header with status badge and actions\n  const headerActions = useMemo(() => (\n    <Space>\n      <Badge\n        status={online === null ? 'processing' : online ? 'success' : 'error'}\n        text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}\n      />\n      <Button icon={<ReloadOutlined />} onClick={fetchStatus} size=\"small\">\n        Refresh\n      </Button>\n      {serviceUrl && (\n        <Button icon={<LinkOutlined />} href={serviceUrl} target=\"_blank\" size=\"small\">\n          Open in New Tab\n        </Button>\n      )}\n    </Space>\n  ), [online, fetchStatus, serviceUrl]);\n\n  useEffect(() => {\n    setPageHeader({ title: 'MailHog', actions: headerActions, fullBleed: true });\n    return () => setPageHeader(null);\n  }, [setPageHeader, headerActions]);\n\n  // Mobile warning\n  if (isMobile) {\n    return (\n      <Result\n        status=\"info\"\n        title=\"Desktop Required\"\n        subTitle=\"MailHog requires a desktop browser with a larger screen.\"\n        icon={<MailOutlined style={{ fontSize: 48 }} />}\n      />\n    );\n  }\n\n  // Loading state\n  if (loading) {\n    return (\n      <div style={{ textAlign: 'center', padding: 80 }}>\n        <Spin size=\"large\" />\n      </div>\n    );\n  }\n\n  // Offline state\n  if (!online || !serviceUrl) {\n    return (\n      <Result\n        status=\"error\"\n        title=\"MailHog Unavailable\"\n        subTitle=\"MailHog is not running or could not be reached. Check that the MailHog container is started.\"\n        extra={\n          <Button type=\"primary\" onClick={fetchStatus}>\n            Retry\n          </Button>\n        }\n      />\n    );\n  }\n\n  // Iframe embed\n  return (\n    <iframe\n      src={serviceUrl}\n      style={{\n        width: '100%',\n        height: 'calc(100vh - 64px)',\n        border: 'none',\n        display: 'block',\n      }}\n      title=\"MailHog\"\n    />\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/mailhog-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  1. Button - Refresh and \"Open in New Tab\" action buttons
  2. Space - Header action button grouping
  3. Badge - Service status indicator (success/error/processing)
  4. Spin - Loading spinner during status check
  5. Grid.useBreakpoint() - Responsive breakpoint detection
  6. Result - Mobile warning and offline error screens
"},{"location":"v2/frontend/pages/admin/mailhog-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#local-component-state-usestate","title":"Local Component State (useState)","text":"
// Service online/offline state\nconst [online, setOnline] = useState<boolean | null>(null);\n\n// Service configuration state (subdomain, domain, port)\nconst [config, setConfig] = useState<ServicesConfig | null>(null);\n\n// Loading state\nconst [loading, setLoading] = useState(true);\n\n// Responsive breakpoint detection\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n
"},{"location":"v2/frontend/pages/admin/mailhog-page/#state-flow","title":"State Flow","text":"
  1. Component Mounts:
  2. fetchStatus() called in useEffect
  3. Parallel API calls:
  4. Sets online to true or false
  5. Sets config with subdomain/domain/port
  6. Sets loading to false

  7. URL Construction:

  8. buildServiceUrl() constructs full service URL from config
  9. Example: http://mailhog.cmlite.org (production with subdomain)
  10. Or: http://localhost:8025 (development with port)

  11. User Clicks Refresh:

  12. fetchStatus() called again
  13. Re-checks service status
  14. Updates online and config states

  15. Service Online:

  16. online is true
  17. Badge shows \"Online\" (green)
  18. Iframe renders with MailHog interface

  19. Service Offline:

  20. online is false
  21. Badge shows \"Offline\" (red)
  22. Error Result displayed with \"Retry\" button
"},{"location":"v2/frontend/pages/admin/mailhog-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/services/status - Check all service health (includes MailHog)
  2. GET /api/services/config - Fetch service configuration (subdomains, ports)
"},{"location":"v2/frontend/pages/admin/mailhog-page/#example-api-calls","title":"Example API Calls","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#1-fetch-service-status","title":"1. Fetch Service Status","text":"
const statusRes = await api.get<ServicesStatus>('/services/status');\nsetOnline(statusRes.data.mailhog.online);\n

Response Format:

{\n  \"mailhog\": { \"online\": true },\n  \"nocodb\": { \"online\": true },\n  \"n8n\": { \"online\": true },\n  \"grafana\": { \"online\": true },\n  \"prometheus\": { \"online\": true }\n}\n

"},{"location":"v2/frontend/pages/admin/mailhog-page/#2-fetch-service-config","title":"2. Fetch Service Config","text":"
const configRes = await api.get<ServicesConfig>('/services/config');\nsetConfig(configRes.data);\n

Response Format:

{\n  \"domain\": \"cmlite.org\",\n  \"mailhogSubdomain\": \"mailhog\",\n  \"mailhogPort\": 8025,\n  \"nocodbSubdomain\": \"db\",\n  \"nocodbPort\": 8091,\n  \"n8nSubdomain\": \"n8n\",\n  \"n8nPort\": 5678\n}\n

"},{"location":"v2/frontend/pages/admin/mailhog-page/#3-build-service-url","title":"3. Build Service URL","text":"
import { buildServiceUrl } from '@/lib/service-url';\n\nconst serviceUrl = buildServiceUrl(\n  config.mailhogSubdomain,  // \"mailhog\"\n  config.domain,             // \"cmlite.org\"\n  config.mailhogPort         // 8025\n);\n\n// Returns: \"http://mailhog.cmlite.org\" (production)\n// Or: \"http://localhost:8025\" (development)\n
"},{"location":"v2/frontend/pages/admin/mailhog-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#complete-parallel-api-fetch-pattern","title":"Complete Parallel API Fetch Pattern","text":"
const fetchStatus = useCallback(async () => {\n  try {\n    setLoading(true);\n\n    // Parallel fetch: status + config\n    const [statusRes, configRes] = await Promise.all([\n      api.get<ServicesStatus>('/services/status'),\n      api.get<ServicesConfig>('/services/config'),\n    ]);\n\n    // Extract MailHog status\n    setOnline(statusRes.data.mailhog.online);\n\n    // Store full config\n    setConfig(configRes.data);\n  } catch (error) {\n    console.error('Failed to fetch MailHog status:', error);\n    setOnline(false);\n  } finally {\n    setLoading(false);\n  }\n}, []);\n
"},{"location":"v2/frontend/pages/admin/mailhog-page/#service-url-builder-utility","title":"Service URL Builder Utility","text":"
// lib/service-url.ts\nexport function buildServiceUrl(\n  subdomain: string,\n  domain: string,\n  port: number\n): string {\n  // Production: use subdomain routing\n  if (process.env.NODE_ENV === 'production') {\n    return `http://${subdomain}.${domain}`;\n  }\n\n  // Development: use localhost + port\n  return `http://localhost:${port}`;\n}\n\n// Usage:\nconst url = buildServiceUrl('mailhog', 'cmlite.org', 8025);\n// Production: \"http://mailhog.cmlite.org\"\n// Development: \"http://localhost:8025\"\n
"},{"location":"v2/frontend/pages/admin/mailhog-page/#conditional-rendering-pattern","title":"Conditional Rendering Pattern","text":"
// Mobile warning (early return)\nif (isMobile) {\n  return <Result status=\"info\" title=\"Desktop Required\" />;\n}\n\n// Loading state (early return)\nif (loading) {\n  return <Spin size=\"large\" />;\n}\n\n// Offline state (early return)\nif (!online || !serviceUrl) {\n  return <Result status=\"error\" title=\"MailHog Unavailable\" />;\n}\n\n// Online state (iframe)\nreturn <iframe src={serviceUrl} />;\n
"},{"location":"v2/frontend/pages/admin/mailhog-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#1-parallel-api-requests","title":"1. Parallel API Requests","text":"

Status and config fetched in parallel with Promise.all():

const [statusRes, configRes] = await Promise.all([\n  api.get<ServicesStatus>('/services/status'),\n  api.get<ServicesConfig>('/services/config'),\n]);\n

Benefit: Total loading time ~200ms (slowest request) instead of ~400ms (sum of both).

"},{"location":"v2/frontend/pages/admin/mailhog-page/#2-usecallback-for-fetchstatus","title":"2. useCallback for fetchStatus","text":"
const fetchStatus = useCallback(async () => {\n  // ... fetch logic\n}, []);\n

Benefit: Function identity stable across re-renders, prevents unnecessary effect triggers.

"},{"location":"v2/frontend/pages/admin/mailhog-page/#3-usememo-for-header-actions","title":"3. useMemo for Header Actions","text":"
const headerActions = useMemo(() => (\n  <Space>\n    <Badge />\n    <Button onClick={fetchStatus} />\n  </Space>\n), [online, fetchStatus, serviceUrl]);\n

Benefit: Header actions only recreated when dependencies change, preventing unnecessary re-renders.

"},{"location":"v2/frontend/pages/admin/mailhog-page/#4-early-mobile-detection","title":"4. Early Mobile Detection","text":"
if (isMobile) {\n  return <Result />;  // No API calls, no iframe\n}\n

Benefit: Avoids unnecessary service checks and iframe loading on mobile devices.

"},{"location":"v2/frontend/pages/admin/mailhog-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#mobile-detection","title":"Mobile Detection","text":"
const screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;  // Mobile if screen width < 768px\n\nif (isMobile) {\n  return (\n    <Result\n      status=\"info\"\n      title=\"Desktop Required\"\n      subTitle=\"MailHog requires a desktop browser with a larger screen.\"\n      icon={<MailOutlined style={{ fontSize: 48 }} />}\n    />\n  );\n}\n

Why Mobile Warning? - MailHog UI has complex table layout (email list) - Email preview requires horizontal space - Buttons/actions too small on mobile screens - Better UX to open in separate tab

"},{"location":"v2/frontend/pages/admin/mailhog-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#keyboard-navigation","title":"Keyboard Navigation","text":"
  1. Tab Key: Cycles through header buttons (Refresh, Open in New Tab)
  2. Enter Key: Activates focused button
  3. Iframe Focus: Tab enters iframe, navigates MailHog interface
"},{"location":"v2/frontend/pages/admin/mailhog-page/#aria-labels","title":"ARIA Labels","text":"
<iframe\n  src={serviceUrl}\n  title=\"MailHog\"  // Screen reader announces iframe purpose\n  aria-label=\"MailHog email testing service\"\n/>\n\n<Button\n  aria-label=\"Refresh MailHog service status\"\n  icon={<ReloadOutlined />}\n>\n  Refresh\n</Button>\n
"},{"location":"v2/frontend/pages/admin/mailhog-page/#color-contrast","title":"Color Contrast","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#problem-service-shows-offline-despite-container-running","title":"Problem: Service Shows \"Offline\" Despite Container Running","text":"

Solutions:

  1. Verify Docker container:

    docker compose ps mailhog\n# Should show \"Up\" status\n

  2. Check MailHog logs:

    docker compose logs mailhog\n# Look for errors\n

  3. Test direct access:

  4. Open http://localhost:8025 in browser
  5. If accessible directly, nginx routing issue

  6. Check nginx config:

  7. Open nginx/conf.d/services.conf
  8. Verify MailHog proxy block exists
  9. Restart nginx: docker compose restart nginx

  10. Verify API endpoint:

  11. Check DevTools Network tab
  12. Look for /api/services/status request
  13. Verify mailhog.online: true in response
"},{"location":"v2/frontend/pages/admin/mailhog-page/#problem-iframe-not-loading","title":"Problem: Iframe Not Loading","text":"

Solutions:

  1. Check CORS/CSP headers:
  2. Open DevTools Console
  3. Look for errors like \"Refused to display in a frame\"
  4. Check nginx X-Frame-Options headers

  5. Verify service URL:

  6. Check console log: console.log(serviceUrl)
  7. Should be valid URL (not null/undefined)

  8. Test URL in new tab:

  9. Click \"Open in New Tab\" button
  10. If opens correctly, iframe issue
  11. If doesn't open, service issue
"},{"location":"v2/frontend/pages/admin/mailhog-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/","title":"MapSettingsPage","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#overview","title":"Overview","text":"

The MapSettingsPage provides configuration for the public map view's default center point and zoom level, plus walk sheet template customization with live preview functionality. It features a city search autocomplete that queries the geocoding service to quickly set coordinates, eliminating manual latitude/longitude entry. The walk sheet preview updates in real-time as settings are edited, showing exactly how printed walk sheets will appear with custom headers, footers, and up to 3 QR codes. The page uses a two-column layout: settings form on left, live walk sheet preview on right.

Route: /app/map/settings Component: admin/src/pages/MapSettingsPage.tsx (433 lines) Auth Required: Yes (SUPER_ADMIN or MAP_ADMIN role recommended) Layout: AppLayout Backend Module: api/src/modules/map/settings/

"},{"location":"v2/frontend/pages/admin/map-settings-page/#screenshot","title":"Screenshot","text":"

[Screenshot: MapSettingsPage with two-column layout. Left column has \"Map Center & Zoom\" card with city search autocomplete (showing \"Ottawa, Canada\" with dropdown suggestions), latitude/longitude inputs (45.4215, -75.6972), and zoom slider (12). Below that is \"Walk Sheet Configuration\" card with Title input (\"Canvassing Walk Sheet\"), Subtitle input (\"District Outreach Campaign 2026\"), Footer textarea, and three QR Code URL + Label input rows. Right column has \"Walk Sheet Preview\" card showing printed form layout with header, 3 QR codes, 2 contact entry blocks (First Name, Last Name, Email, Phone, Address, Support Level circles, Sign Request Y/N, Sign Size R/L/U, Visited Date), and Notes section. Top-right has \"Print Walk Sheet\" and \"Save Settings\" buttons.]

"},{"location":"v2/frontend/pages/admin/map-settings-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#setting-map-center-via-city-search","title":"Setting Map Center via City Search","text":"
  1. Navigate to /app/map/settings
  2. Locate \"Map Center & Zoom\" card (top of left column)
  3. Click city search autocomplete input field
  4. Start typing city name (e.g., \"Ottawa\")
  5. After 400ms delay, search results appear in dropdown:
  6. Display name: \"Ottawa, Ontario, Canada\"
  7. Type tag: \"city\" (gray text, right side)
  8. Click desired city from dropdown
  9. Coordinates auto-fill:
  10. Latitude: 45.4215
  11. Longitude: -75.6972
  12. Zoom: 12 (default zoom for city-level view)
  13. Success message: \"Coordinates auto-filled. Fine-tune below.\"
  14. Adjust zoom slider if needed (e.g., 14 for closer view)
  15. Click \"Save Settings\" button (top-right header)

Search Features: - Debounced search: 400ms delay prevents API spam during typing - Minimum 2 characters: Search requires at least 2 characters - Limit 5 results: Top 5 most relevant results shown - Result types: city, town, village, suburb, neighbourhood - Display format: \"City, State/Province, Country\" + type tag

"},{"location":"v2/frontend/pages/admin/map-settings-page/#setting-map-center-manually","title":"Setting Map Center Manually","text":"
  1. Locate latitude/longitude input fields
  2. Enter precise coordinates:
  3. Latitude: -90 to 90 (e.g., 45.4215)
  4. Longitude: -180 to 180 (e.g., -75.6972)
  5. Decimal precision: 4 decimal places = ~10 meter accuracy
  6. Adjust zoom slider (2-19 range):
  7. 2-5: Country/continent level
  8. 6-9: State/province level
  9. 10-12: City level (default)
  10. 13-15: Neighborhood level
  11. 16-19: Street level
  12. Click \"Save Settings\"

Use Cases: - Setting map center to campaign office location - Centering on specific neighborhood - Centering on landmark (e.g., Parliament Hill)

"},{"location":"v2/frontend/pages/admin/map-settings-page/#customizing-walk-sheet-header","title":"Customizing Walk Sheet Header","text":"
  1. Scroll to \"Walk Sheet Configuration\" card
  2. Modify Title field (e.g., \"Canvassing Walk Sheet\")
  3. Modify Subtitle field (e.g., \"District Outreach Campaign 2026\")
  4. Observe live preview (right column):
  5. Header updates immediately as you type
  6. Title appears in large bold font (18pt)
  7. Subtitle appears below in smaller font (12pt)
  8. Click \"Save Settings\" when satisfied

Best Practices: - Title: Keep short (< 50 characters), campaign name or purpose - Subtitle: Add date, district, or organizer name - Avoid: Long text (will overflow on printed page)

"},{"location":"v2/frontend/pages/admin/map-settings-page/#adding-qr-codes","title":"Adding QR Codes","text":"
  1. Locate \"QR Codes\" section (under footer field)
  2. Fill in QR Code 1 URL (e.g., \"https://cmlite.org/campaign-info\")
  3. Fill in QR Code 1 Label (e.g., \"Campaign Info\")
  4. Observe live preview:
  5. QR code appears below header (generated via /api/qr endpoint)
  6. Label appears below QR code in small font (9pt)
  7. Repeat for QR Code 2 and 3 (optional)
  8. Click \"Save Settings\"

QR Code Use Cases: - Campaign website: Link to campaign homepage - Survey/feedback: Google Form or Typeform link - Donation page: Link to fundraising platform - Social media: Link to Facebook page or Twitter profile - Contact form: Link to volunteer signup form

QR Code Behavior: - Empty URL: QR code not rendered (skipped) - Empty label: QR code rendered without label - Long label: Label truncates (keep < 20 characters) - Invalid URL: QR code still generated (ensure URL is correct)

"},{"location":"v2/frontend/pages/admin/map-settings-page/#customizing-walk-sheet-footer","title":"Customizing Walk Sheet Footer","text":"
  1. Locate Footer field (multi-line textarea)
  2. Enter footer text (e.g., \"Return completed sheets to campaign office. Questions? Call 613-555-0100.\")
  3. Observe live preview:
  4. Footer appears at bottom of walk sheet
  5. Centered text, small font (9pt)
  6. Gray text color for subtlety
  7. Click \"Save Settings\"

Footer Content Suggestions: - Return instructions (where to submit completed sheets) - Contact information (phone, email) - Legal disclaimer (privacy policy compliance) - Thank you message (\"Thank you for volunteering!\")

"},{"location":"v2/frontend/pages/admin/map-settings-page/#printing-walk-sheet","title":"Printing Walk Sheet","text":"

Option 1: Print from Preview

  1. Click \"Print\" button in preview card header (top-right)
  2. Browser print dialog opens
  3. Configure print settings:
  4. Paper size: Letter (8.5\" \u00d7 11\")
  5. Orientation: Portrait
  6. Margins: Default (or custom 0.5\")
  7. Scale: 100% (do not scale)
  8. Click \"Print\" button in dialog
  9. Walk sheet prints exactly as shown in preview

Option 2: Print from Header Button

  1. Click \"Print Walk Sheet\" button (page header, next to Save Settings)
  2. Same print dialog appears
  3. Same print settings apply

Print Optimization:

The page includes print-specific CSS:

@media print {\n  body * { visibility: hidden !important; }\n  .walk-sheet-print, .walk-sheet-print * { visibility: visible !important; }\n  .walk-sheet-print {\n    position: fixed !important;\n    left: 0 !important;\n    top: 0 !important;\n    width: 8.5in !important;\n    height: 11in !important;\n    padding: 0.4in 0.5in !important;\n  }\n}\n

Result: - Only walk sheet prints (no navigation, buttons, etc.) - Exactly 8.5\" \u00d7 11\" Letter size - QR codes print with high contrast (print-color-adjust: exact) - Clean professional appearance

"},{"location":"v2/frontend/pages/admin/map-settings-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#ant-design-components-used","title":"Ant Design Components Used","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#two-column-layout","title":"Two-Column Layout","text":"
<Row gutter={24}>\n  {/* Left column: Settings form */}\n  <Col xs={24} lg={10}>\n    <Form form={form} onFinish={handleSave} layout=\"vertical\">\n      <Card title=\"Map Center & Zoom\">{/* ... */}</Card>\n      <Card title=\"Walk Sheet Configuration\">{/* ... */}</Card>\n    </Form>\n  </Col>\n\n  {/* Right column: Live walk sheet preview */}\n  <Col xs={24} lg={14}>\n    <Card title=\"Walk Sheet Preview\">{/* ... */}</Card>\n  </Col>\n</Row>\n

Responsive Breakpoints: - Mobile (xs, <992px): Stacked layout (form on top, preview below) - Desktop (lg, \u2265992px): Side-by-side layout (form 40% width, preview 60% width)

"},{"location":"v2/frontend/pages/admin/map-settings-page/#city-search-autocomplete","title":"City Search Autocomplete","text":"
<AutoComplete\n  value={citySearch}\n  options={cityOptions}\n  onSearch={handleCitySearch}\n  onSelect={handleCitySelect}\n  placeholder=\"Search for a city to auto-fill coordinates...\"\n  style={{ width: '100%' }}\n  suffixIcon={citySearching ? <Spin size=\"small\" /> : <SearchOutlined />}\n/>\n

City Search Handler:

const handleCitySearch = useCallback((value: string) => {\n  setCitySearch(value);\n  clearTimeout(cityTimerRef.current);\n\n  // Require minimum 2 characters\n  if (value.length < 2) {\n    setCityOptions([]);\n    return;\n  }\n\n  // Debounce 400ms\n  cityTimerRef.current = setTimeout(async () => {\n    setCitySearching(true);\n    try {\n      const { data } = await api.get<GeocodeSearchResult[]>('/map/geocoding/search', {\n        params: { q: value, limit: 5 },\n      });\n\n      setCityOptions(data.map((r) => ({\n        value: r.displayName,\n        label: (\n          <div style={{ display: 'flex', justifyContent: 'space-between' }}>\n            <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 300 }}>\n              {r.displayName}\n            </span>\n            <span style={{ color: '#888', fontSize: 11, marginLeft: 8, flexShrink: 0 }}>\n              {r.type}\n            </span>\n          </div>\n        ),\n        result: r,\n      })));\n    } catch {\n      setCityOptions([]);\n    } finally {\n      setCitySearching(false);\n    }\n  }, 400);\n}, []);\n

City Select Handler:

const handleCitySelect = useCallback((_value: string, option: { result: GeocodeSearchResult }) => {\n  // Auto-fill coordinates from selected result\n  form.setFieldsValue({\n    latitude: option.result.latitude,\n    longitude: option.result.longitude,\n    zoom: 12,  // Default zoom for city-level view\n  });\n\n  // Clear search\n  setCitySearch('');\n  setCityOptions([]);\n\n  message.success('Coordinates auto-filled. Fine-tune below.');\n}, [form]);\n
"},{"location":"v2/frontend/pages/admin/map-settings-page/#live-walk-sheet-preview","title":"Live Walk Sheet Preview","text":"
// Watch all form values for live preview\nconst watched = Form.useWatch(undefined, form) as Record<string, string | undefined> | undefined;\n\n// QR codes from live form values\nconst qrCodes = [\n  { url: watched?.qrCode1Url, label: watched?.qrCode1Label },\n  { url: watched?.qrCode2Url, label: watched?.qrCode2Label },\n  { url: watched?.qrCode3Url, label: watched?.qrCode3Label },\n].filter((qr) => qr.url);  // Only include QR codes with URLs\n\nreturn (\n  <div className=\"walk-sheet-print\" style={{ /* print styles */ }}>\n    {/* Header */}\n    <div style={{ borderBottom: '2px solid #000', paddingBottom: 6 }}>\n      <div style={{ fontSize: 18, fontWeight: 700, textAlign: 'center' }}>\n        {watched?.walkSheetTitle || 'Walk Sheet'}\n      </div>\n      {watched?.walkSheetSubtitle && (\n        <div style={{ fontSize: 12, textAlign: 'center', marginTop: 2 }}>\n          {watched.walkSheetSubtitle}\n        </div>\n      )}\n    </div>\n\n    {/* QR Codes */}\n    {qrCodes.length > 0 && (\n      <div style={{ display: 'flex', justifyContent: 'center', gap: 32 }}>\n        {qrCodes.map((qr, i) => (\n          <div key={i} style={{ textAlign: 'center' }}>\n            <img\n              src={`/api/qr?text=${encodeURIComponent(qr.url!)}&size=200`}\n              alt={qr.label || `QR Code ${i + 1}`}\n              style={{ width: 80, height: 80 }}\n            />\n            {qr.label && <div style={{ fontSize: 9 }}>{qr.label}</div>}\n          </div>\n        ))}\n      </div>\n    )}\n\n    {/* Contact entry blocks */}\n    {[1, 2].map((blockNum) => (\n      <div key={blockNum}>\n        <FormRow left=\"First Name\" right=\"Last Name\" />\n        <FormRow left=\"Email\" right=\"Phone\" />\n        <FormRow left=\"Address\" right=\"Unit Number\" />\n        {/* Support Level, Sign Request, Sign Size, Visited Date */}\n      </div>\n    ))}\n\n    {/* Notes section */}\n    <div style={{ marginTop: 8 }}>\n      <div style={{ fontSize: 9, color: '#666' }}>Notes &amp; Comments</div>\n      <div style={{ border: '1px solid #999', minHeight: 80 }} />\n    </div>\n\n    {/* Footer */}\n    {watched?.walkSheetFooter && (\n      <div style={{ marginTop: 10, textAlign: 'center', fontSize: 9 }}>\n        {watched.walkSheetFooter}\n      </div>\n    )}\n  </div>\n);\n

Live Preview Features: - Form.useWatch: Monitors all form fields for changes - Immediate updates: No need to click \"Save\" to see preview - Conditional rendering: QR codes only shown if URL provided - Fallback values: Default \"Walk Sheet\" title if empty - Print-ready: Preview matches actual printed output

"},{"location":"v2/frontend/pages/admin/map-settings-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#local-state-no-zustand-store","title":"Local State (No Zustand Store)","text":"
const [form] = Form.useForm();\nconst [loading, setLoading] = useState(true);\nconst [saving, setSaving] = useState(false);\nconst [citySearch, setCitySearch] = useState('');\nconst [cityOptions, setCityOptions] = useState<Array<{ value: string; label: React.ReactNode; result: GeocodeSearchResult }>>([]);\nconst [citySearching, setCitySearching] = useState(false);\nconst cityTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\nconst printRef = useRef<HTMLDivElement>(null);\n

State Variables: - form (Form): Ant Design form instance (all settings inputs) - loading (boolean): Initial page load state - saving (boolean): Save button loading state - citySearch (string): City search input value - cityOptions (array): Autocomplete dropdown options - citySearching (boolean): City search loading indicator - cityTimerRef (ref): Debounce timer for city search - printRef (ref): Reference to walk sheet preview div (for future print enhancements)

No Global State:

This page does NOT use Zustand stores. Map settings are fetched directly from the API and stored in the form. This is appropriate because: - Map settings are admin-only configuration - Settings change infrequently (set once during setup) - No need to share state between pages (public map fetches settings independently) - Simpler architecture without store overhead

"},{"location":"v2/frontend/pages/admin/map-settings-page/#form-initialization","title":"Form Initialization","text":"
const fetchSettings = useCallback(async () => {\n  try {\n    const { data } = await api.get<MapSettings>('/map/settings');\n    form.setFieldsValue({\n      latitude: data.latitude ? parseFloat(data.latitude) : 45.4215,\n      longitude: data.longitude ? parseFloat(data.longitude) : -75.6972,\n      zoom: data.zoom ?? 12,\n      walkSheetTitle: data.walkSheetTitle,\n      walkSheetSubtitle: data.walkSheetSubtitle,\n      walkSheetFooter: data.walkSheetFooter,\n      qrCode1Url: data.qrCode1Url,\n      qrCode1Label: data.qrCode1Label,\n      qrCode2Url: data.qrCode2Url,\n      qrCode2Label: data.qrCode2Label,\n      qrCode3Url: data.qrCode3Url,\n      qrCode3Label: data.qrCode3Label,\n    });\n  } catch {\n    message.error('Failed to load map settings');\n  } finally {\n    setLoading(false);\n  }\n}, [form]);\n\nuseEffect(() => {\n  fetchSettings();\n}, [fetchSettings]);\n

Default Values:

If settings not yet configured (first time), defaults are used: - Latitude: 45.4215 (Ottawa, Canada) - Longitude: -75.6972 (Ottawa, Canada) - Zoom: 12 (city-level view) - Walk Sheet Title: empty (shows \"Walk Sheet\" placeholder in preview) - Other fields: empty

"},{"location":"v2/frontend/pages/admin/map-settings-page/#debounced-city-search","title":"Debounced City Search","text":"
const handleCitySearch = useCallback((value: string) => {\n  setCitySearch(value);\n  clearTimeout(cityTimerRef.current);\n\n  if (value.length < 2) {\n    setCityOptions([]);\n    return;\n  }\n\n  cityTimerRef.current = setTimeout(async () => {\n    setCitySearching(true);\n    try {\n      const { data } = await api.get<GeocodeSearchResult[]>('/map/geocoding/search', {\n        params: { q: value, limit: 5 },\n      });\n      setCityOptions(data.map((r) => ({ /* ... */ })));\n    } catch {\n      setCityOptions([]);\n    } finally {\n      setCitySearching(false);\n    }\n  }, 400);\n}, []);\n\nuseEffect(() => {\n  return () => clearTimeout(cityTimerRef.current);  // Cleanup on unmount\n}, []);\n

Why 400ms Debounce?

"},{"location":"v2/frontend/pages/admin/map-settings-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose Auth GET /api/map/settings Load current settings Required PUT /api/map/settings Update settings Required GET /api/map/geocoding/search Search for cities/places Required GET /api/qr Generate QR code PNG Public (no auth)"},{"location":"v2/frontend/pages/admin/map-settings-page/#load-map-settings","title":"Load Map Settings","text":"

Request:

const { data } = await api.get<MapSettings>('/map/settings');\n

Response (200 OK):

{\n  \"id\": \"settings_singleton\",\n  \"latitude\": \"45.4215\",\n  \"longitude\": \"-75.6972\",\n  \"zoom\": 12,\n  \"walkSheetTitle\": \"Canvassing Walk Sheet\",\n  \"walkSheetSubtitle\": \"District Outreach Campaign 2026\",\n  \"walkSheetFooter\": \"Return completed sheets to campaign office. Questions? Call 613-555-0100.\",\n  \"qrCode1Url\": \"https://cmlite.org/campaign-info\",\n  \"qrCode1Label\": \"Campaign Info\",\n  \"qrCode2Url\": \"https://forms.gle/abc123\",\n  \"qrCode2Label\": \"Volunteer Survey\",\n  \"qrCode3Url\": null,\n  \"qrCode3Label\": null,\n  \"createdAt\": \"2026-01-15T10:30:00.000Z\",\n  \"updatedAt\": \"2026-02-10T14:25:00.000Z\"\n}\n

Response Fields: - latitude (string): Default map center latitude (stored as string for precision) - longitude (string): Default map center longitude - zoom (number): Default map zoom level (2-19) - walkSheetTitle (string | null): Walk sheet header title - walkSheetSubtitle (string | null): Walk sheet header subtitle - walkSheetFooter (string | null): Walk sheet footer text - qrCode1Url (string | null): First QR code URL - qrCode1Label (string | null): First QR code label - qrCode2Url (string | null): Second QR code URL - qrCode2Label (string | null): Second QR code label - qrCode3Url (string | null): Third QR code URL - qrCode3Label (string | null): Third QR code label

Backend Implementation:

Map settings are stored as singleton record (only one row in database):

const settings = await prisma.mapSettings.findFirst();\nif (!settings) {\n  // Return defaults if not yet configured\n  return {\n    latitude: '45.4215',\n    longitude: '-75.6972',\n    zoom: 12,\n    walkSheetTitle: null,\n    // ... other fields null\n  };\n}\nreturn settings;\n
"},{"location":"v2/frontend/pages/admin/map-settings-page/#update-map-settings","title":"Update Map Settings","text":"

Request:

const values = {\n  latitude: 45.4215,\n  longitude: -75.6972,\n  zoom: 14,\n  walkSheetTitle: 'Canvassing Walk Sheet',\n  walkSheetSubtitle: 'District Outreach Campaign 2026',\n  walkSheetFooter: 'Return completed sheets to campaign office.',\n  qrCode1Url: 'https://cmlite.org/campaign-info',\n  qrCode1Label: 'Campaign Info',\n  qrCode2Url: null,\n  qrCode2Label: null,\n  qrCode3Url: null,\n  qrCode3Label: null,\n};\n\nawait api.put('/map/settings', values);\n

Request Body Schema:

{\n  latitude?: number;          // Optional, -90 to 90\n  longitude?: number;         // Optional, -180 to 180\n  zoom?: number;              // Optional, 2 to 19\n  walkSheetTitle?: string;    // Optional, max 255 chars\n  walkSheetSubtitle?: string; // Optional, max 255 chars\n  walkSheetFooter?: string;   // Optional, max 1000 chars\n  qrCode1Url?: string | null; // Optional, valid URL or null\n  qrCode1Label?: string | null;\n  qrCode2Url?: string | null;\n  qrCode2Label?: string | null;\n  qrCode3Url?: string | null;\n  qrCode3Label?: string | null;\n}\n

Response (200 OK):

{\n  \"id\": \"settings_singleton\",\n  \"latitude\": \"45.4215\",\n  \"longitude\": \"-75.6972\",\n  \"zoom\": 14,\n  \"walkSheetTitle\": \"Canvassing Walk Sheet\",\n  \"walkSheetSubtitle\": \"District Outreach Campaign 2026\",\n  \"walkSheetFooter\": \"Return completed sheets to campaign office.\",\n  \"qrCode1Url\": \"https://cmlite.org/campaign-info\",\n  \"qrCode1Label\": \"Campaign Info\",\n  \"qrCode2Url\": null,\n  \"qrCode2Label\": null,\n  \"qrCode3Url\": null,\n  \"qrCode3Label\": null,\n  \"createdAt\": \"2026-01-15T10:30:00.000Z\",\n  \"updatedAt\": \"2026-02-11T10:45:00.000Z\"\n}\n

Backend Implementation (Upsert):

const settings = await prisma.mapSettings.upsert({\n  where: { id: 'settings_singleton' },\n  create: {\n    id: 'settings_singleton',\n    latitude: values.latitude?.toString(),\n    longitude: values.longitude?.toString(),\n    zoom: values.zoom,\n    // ... other fields\n  },\n  update: {\n    latitude: values.latitude?.toString(),\n    longitude: values.longitude?.toString(),\n    zoom: values.zoom,\n    // ... other fields\n  },\n});\n

Upsert Logic: - If settings don't exist (first time), create new record - If settings exist, update existing record - Ensures singleton pattern (only one settings record)

"},{"location":"v2/frontend/pages/admin/map-settings-page/#search-for-cities-geocoding","title":"Search for Cities (Geocoding)","text":"

Request:

const { data } = await api.get<GeocodeSearchResult[]>('/map/geocoding/search', {\n  params: {\n    q: 'Ottawa',\n    limit: 5,\n  },\n});\n

Query Parameters: - q (string, required): Search query (city name, address, landmark) - limit (number, optional): Maximum results to return (default: 10, max: 20)

Response (200 OK):

[\n  {\n    \"displayName\": \"Ottawa, Ontario, Canada\",\n    \"latitude\": 45.4215,\n    \"longitude\": -75.6972,\n    \"type\": \"city\",\n    \"importance\": 0.98,\n    \"boundingBox\": {\n      \"minLat\": 45.2,\n      \"maxLat\": 45.6,\n      \"minLon\": -76.0,\n      \"maxLon\": -75.4\n    }\n  },\n  {\n    \"displayName\": \"Ottawa, Kansas, United States\",\n    \"latitude\": 38.6156,\n    \"longitude\": -95.2678,\n    \"type\": \"city\",\n    \"importance\": 0.75\n  },\n  {\n    \"displayName\": \"Ottawa, Illinois, United States\",\n    \"latitude\": 41.3456,\n    \"longitude\": -88.8426,\n    \"type\": \"city\",\n    \"importance\": 0.72\n  }\n]\n

Response Fields: - displayName (string): Human-readable location name (e.g., \"Ottawa, Ontario, Canada\") - latitude (number): Latitude coordinate - longitude (number): Longitude coordinate - type (string): Location type (city, town, village, suburb, neighbourhood, etc.) - importance (number): Relevance score (0.0-1.0, higher = more relevant) - boundingBox (object, optional): Geographic bounds for larger areas

Sorting: - Results sorted by importance DESC (most relevant first) - Limited to top N results (default 5 for autocomplete)

Backend Implementation:

Multi-provider geocoding service (see Geocoding Service documentation):

const results = await geocodingService.search(query, { limit });\nreturn results.map((r) => ({\n  displayName: r.display_name,\n  latitude: r.lat,\n  longitude: r.lon,\n  type: r.type,\n  importance: r.importance,\n}));\n

Providers Used (in order of preference): 1. Nominatim (OpenStreetMap) 2. ArcGIS 3. Photon 4. Mapbox (if API key provided)

"},{"location":"v2/frontend/pages/admin/map-settings-page/#generate-qr-code","title":"Generate QR Code","text":"

Request:

const qrCodeUrl = `/api/qr?text=${encodeURIComponent('https://cmlite.org/campaign-info')}&size=200`;\n\n<img src={qrCodeUrl} alt=\"Campaign Info QR Code\" style={{ width: 80, height: 80 }} />\n

Query Parameters: - text (string, required): URL or text to encode (URL-encoded) - size (number, optional): QR code size in pixels (default: 200, max: 1000)

Response (200 OK):

Binary PNG image data (Content-Type: image/png)

Example URLs: - Campaign website: /api/qr?text=https%3A%2F%2Fcmlite.org%2Fcampaign-info&size=200 - Google Form: /api/qr?text=https%3A%2F%2Fforms.gle%2Fabc123&size=200 - Phone number: /api/qr?text=tel%3A%2B16135550100&size=200

QR Code Generation:

Backend uses qrcode npm package:

import QRCode from 'qrcode';\n\nconst qrBuffer = await QRCode.toBuffer(text, {\n  width: size,\n  margin: 1,\n  errorCorrectionLevel: 'M',\n});\n\nres.set('Content-Type', 'image/png');\nres.send(qrBuffer);\n

Error Correction Level: - M (Medium): Can recover from 15% damage - Balanced between size and error tolerance - Suitable for most use cases (printed walk sheets)

"},{"location":"v2/frontend/pages/admin/map-settings-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#complete-city-search-flow","title":"Complete City Search Flow","text":"
const handleCitySearch = useCallback((value: string) => {\n  setCitySearch(value);\n  clearTimeout(cityTimerRef.current);\n\n  // Require minimum 2 characters\n  if (value.length < 2) {\n    setCityOptions([]);\n    return;\n  }\n\n  // Debounce 400ms\n  cityTimerRef.current = setTimeout(async () => {\n    setCitySearching(true);\n    try {\n      // Query geocoding service\n      const { data } = await api.get<GeocodeSearchResult[]>('/map/geocoding/search', {\n        params: { q: value, limit: 5 },\n      });\n\n      // Map results to autocomplete options\n      setCityOptions(data.map((r) => ({\n        value: r.displayName,\n        label: (\n          <div style={{ display: 'flex', justifyContent: 'space-between' }}>\n            <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 300 }}>\n              {r.displayName}\n            </span>\n            <span style={{ color: '#888', fontSize: 11, marginLeft: 8, flexShrink: 0 }}>\n              {r.type}\n            </span>\n          </div>\n        ),\n        result: r,  // Store full result for onSelect handler\n      })));\n    } catch {\n      setCityOptions([]);\n    } finally {\n      setCitySearching(false);\n    }\n  }, 400);\n}, []);\n\nconst handleCitySelect = useCallback((_value: string, option: { result: GeocodeSearchResult }) => {\n  // Auto-fill coordinates from selected result\n  form.setFieldsValue({\n    latitude: option.result.latitude,\n    longitude: option.result.longitude,\n    zoom: 12,  // Default zoom for city-level view\n  });\n\n  // Clear search\n  setCitySearch('');\n  setCityOptions([]);\n\n  // Notify user\n  message.success('Coordinates auto-filled. Fine-tune below.');\n}, [form]);\n\n// Cleanup timer on unmount\nuseEffect(() => {\n  return () => clearTimeout(cityTimerRef.current);\n}, []);\n

Key Steps: 1. Check minimum length (2 characters) before searching 2. Debounce 400ms to prevent API spam 3. Query geocoding service with limit=5 4. Map results to autocomplete options with custom labels 5. Store full result object for use in onSelect handler 6. Auto-fill form fields when user selects result 7. Clear search input after selection 8. Show success message confirming auto-fill

"},{"location":"v2/frontend/pages/admin/map-settings-page/#live-walk-sheet-preview_1","title":"Live Walk Sheet Preview","text":"
// Watch all form values for live preview\nconst watched = Form.useWatch(undefined, form) as Record<string, string | undefined> | undefined;\n\n// Extract QR codes (filter out empty URLs)\nconst qrCodes = [\n  { url: watched?.qrCode1Url, label: watched?.qrCode1Label },\n  { url: watched?.qrCode2Url, label: watched?.qrCode2Label },\n  { url: watched?.qrCode3Url, label: watched?.qrCode3Label },\n].filter((qr) => qr.url);\n\nreturn (\n  <div className=\"walk-sheet-print\" style={{ /* ... */ }}>\n    {/* Header */}\n    <div style={{ borderBottom: '2px solid #000', paddingBottom: 6 }}>\n      <div style={{ fontSize: 18, fontWeight: 700, textAlign: 'center' }}>\n        {watched?.walkSheetTitle || 'Walk Sheet'}\n      </div>\n      {watched?.walkSheetSubtitle && (\n        <div style={{ fontSize: 12, textAlign: 'center', color: '#333' }}>\n          {watched.walkSheetSubtitle}\n        </div>\n      )}\n    </div>\n\n    {/* QR Codes */}\n    {qrCodes.length > 0 && (\n      <div style={{ display: 'flex', justifyContent: 'center', gap: 32 }}>\n        {qrCodes.map((qr, i) => (\n          <div key={i} style={{ textAlign: 'center' }}>\n            <img\n              src={`/api/qr?text=${encodeURIComponent(qr.url!)}&size=200`}\n              alt={qr.label || `QR Code ${i + 1}`}\n              style={{ width: 80, height: 80 }}\n            />\n            {qr.label && <div style={{ fontSize: 9 }}>{qr.label}</div>}\n          </div>\n        ))}\n      </div>\n    )}\n\n    {/* Contact entry blocks (2 blocks per page) */}\n    {[1, 2].map((blockNum) => (\n      <div key={blockNum}>\n        {blockNum > 1 && <div style={{ borderTop: '1px dashed #999' }} />}\n        <FormRow left=\"First Name\" right=\"Last Name\" />\n        <FormRow left=\"Email\" right=\"Phone\" />\n        <FormRow left=\"Address\" right=\"Unit Number\" />\n        {/* Support Level, Sign Request, Sign Size, Visited Date */}\n      </div>\n    ))}\n\n    {/* Footer */}\n    {watched?.walkSheetFooter && (\n      <div style={{ marginTop: 10, textAlign: 'center', fontSize: 9, color: '#666' }}>\n        {watched.walkSheetFooter}\n      </div>\n    )}\n  </div>\n);\n

Live Preview Features: - Form.useWatch(undefined, form): Watches all form fields (no specific field specified) - Immediate updates: Preview updates as user types (no debounce needed for preview) - Conditional rendering: QR codes only shown if URL provided - Fallback values: Shows \"Walk Sheet\" if title empty - Print-ready styling: Matches actual printed output exactly

"},{"location":"v2/frontend/pages/admin/map-settings-page/#print-specific-css","title":"Print-Specific CSS","text":"
<style>{`\n  @media print {\n    /* Hide everything except walk sheet */\n    body * { visibility: hidden !important; }\n    .walk-sheet-print, .walk-sheet-print * { visibility: visible !important; }\n\n    /* Position walk sheet at top-left of page */\n    .walk-sheet-print {\n      position: fixed !important;\n      left: 0 !important;\n      top: 0 !important;\n      width: 8.5in !important;\n      height: 11in !important;\n      padding: 0.4in 0.5in !important;\n      background: white !important;\n      color: black !important;\n      box-sizing: border-box !important;\n    }\n\n    /* Ensure QR codes print with high contrast */\n    .walk-sheet-print img {\n      print-color-adjust: exact;\n      -webkit-print-color-adjust: exact;\n    }\n\n    /* Set page size */\n    @page {\n      size: letter;\n      margin: 0;\n    }\n  }\n`}</style>\n

CSS Explanation:

"},{"location":"v2/frontend/pages/admin/map-settings-page/#form-submission-handler","title":"Form Submission Handler","text":"
const handleSave = async (values: Record<string, unknown>) => {\n  setSaving(true);\n  try {\n    await api.put('/map/settings', values);\n    message.success('Map settings saved');\n  } catch (err: unknown) {\n    const msg =\n      (err as { response?: { data?: { error?: { message?: string } } } })\n        ?.response?.data?.error?.message || 'Failed to save settings';\n    message.error(msg);\n  } finally {\n    setSaving(false);\n  }\n};\n

Error Handling:

Extracts specific error message from API response:

// API error response format:\n{\n  \"error\": {\n    \"message\": \"Latitude must be between -90 and 90\"\n  }\n}\n\n// Extracted error message shown to user:\n\"Latitude must be between -90 and 90\"\n

Generic Fallback:

If error message not in expected format, shows generic message:

\"Failed to save settings\"\n
"},{"location":"v2/frontend/pages/admin/map-settings-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#debounced-city-search-400ms","title":"Debounced City Search (400ms)","text":"

City search queries geocoding service after 400ms delay:

cityTimerRef.current = setTimeout(async () => {\n  // Query geocoding service\n}, 400);\n

Performance Impact: - Without debounce: Typing \"Ottawa\" (6 chars) = 6 API calls - With 400ms debounce: Typing \"Ottawa\" = 1 API call (after 400ms pause) - 84% reduction in API calls

Why 400ms?

"},{"location":"v2/frontend/pages/admin/map-settings-page/#live-preview-performance","title":"Live Preview Performance","text":"

Walk sheet preview updates on every form keystroke:

const watched = Form.useWatch(undefined, form);\n

Performance Impact: - Re-renders: Preview re-renders on every form value change - Expensive renders: QR code images regenerated (browser fetches new PNG from /api/qr) - Trade-off: Immediate feedback vs. performance

Mitigation:

Consider debouncing QR code updates:

const debouncedQrUrls = useDebounce([watched?.qrCode1Url, watched?.qrCode2Url, watched?.qrCode3Url], 500);\n

Result: - User types QR code URL - Preview shows old QR code for 500ms - After 500ms pause, QR code updates to new URL - Reduces API calls to /api/qr endpoint

"},{"location":"v2/frontend/pages/admin/map-settings-page/#print-css-performance","title":"Print CSS Performance","text":"

Print-specific CSS uses visibility: hidden instead of display: none:

body * { visibility: hidden !important; }\n.walk-sheet-print * { visibility: visible !important; }\n

Why Visibility?

Comparison:

"},{"location":"v2/frontend/pages/admin/map-settings-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#two-column-layout_1","title":"Two-Column Layout","text":"

Settings form and preview adapt to viewport width:

<Row gutter={24}>\n  <Col xs={24} lg={10}>  {/* Full width mobile, 40% desktop */}\n    <Form>{/* ... */}</Form>\n  </Col>\n  <Col xs={24} lg={14}>  {/* Full width mobile, 60% desktop */}\n    <Card title=\"Walk Sheet Preview\">{/* ... */}</Card>\n  </Col>\n</Row>\n

Responsive Breakpoints: - Mobile (xs, <992px): Stacked layout (form on top, preview below) - Desktop (lg, \u2265992px): Side-by-side layout (form 40%, preview 60%)

Why 40/60 Split?

"},{"location":"v2/frontend/pages/admin/map-settings-page/#mobile-print-behavior","title":"Mobile Print Behavior","text":"

On mobile devices, print preview is less practical:

Option 1: Hide preview on mobile

<Col xs={0} lg={14}>  {/* Hidden on mobile (xs={0}) */}\n  <Card title=\"Walk Sheet Preview\">{/* ... */}</Card>\n</Col>\n

Option 2: Show preview below form

<Col xs={24} lg={14}>  {/* Full width mobile, shown below form */}\n  <Card title=\"Walk Sheet Preview\">{/* ... */}</Card>\n</Col>\n

Current Implementation: Option 2 (show preview below form on mobile)

Rationale:

"},{"location":"v2/frontend/pages/admin/map-settings-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#keyboard-navigation","title":"Keyboard Navigation","text":"

All interactive elements are keyboard-accessible:

City Search Autocomplete: - Tab: Focus on autocomplete input - Type: Enter city name - Down Arrow: Navigate dropdown options - Enter: Select focused option - Escape: Close dropdown

Form Fields: - Tab: Move between fields (latitude \u2192 longitude \u2192 zoom \u2192 title...) - Arrow Keys: Adjust zoom slider value - Enter: Submit form (same as clicking \"Save Settings\")

Buttons: - Tab: Focus on button - Enter/Space: Activate button (print, save)

"},{"location":"v2/frontend/pages/admin/map-settings-page/#screen-reader-support","title":"Screen Reader Support","text":"

All elements have proper ARIA labels:

City Search:

<AutoComplete\n  placeholder=\"Search for a city to auto-fill coordinates...\"\n  aria-label=\"Search for a city to auto-fill map center coordinates\"\n  aria-describedby=\"city-search-hint\"\n/>\n<Text id=\"city-search-hint\" type=\"secondary\">\n  Search for a city to auto-fill coordinates. Fine-tune below.\n</Text>\n

Form Fields:

<Form.Item name=\"latitude\" label=\"Latitude\">\n  <InputNumber\n    aria-label=\"Map center latitude in decimal degrees\"\n    aria-valuemin={-90}\n    aria-valuemax={90}\n  />\n</Form.Item>\n

Walk Sheet Preview:

<Card\n  title=\"Walk Sheet Preview\"\n  aria-label=\"Live preview of walk sheet with current settings\"\n>\n  {/* ... */}\n</Card>\n

"},{"location":"v2/frontend/pages/admin/map-settings-page/#color-contrast","title":"Color Contrast","text":"

All text meets WCAG AA standards:

Form Labels: - Label text: rgba(0,0,0,0.85) on white = 13.6:1 contrast (AAA)

Helper Text: - Helper text: rgba(0,0,0,0.45) on white = 7.0:1 contrast (AA)

Walk Sheet Preview: - Header text: #000 on white = 21:1 contrast (AAA) - Field labels: #666 on white = 5.7:1 contrast (AA)

"},{"location":"v2/frontend/pages/admin/map-settings-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#city-search-not-working","title":"City Search Not Working","text":"

Problem: Type city name in autocomplete, but no dropdown suggestions appear.

Diagnosis:

Check browser console for errors:

GET /api/map/geocoding/search?q=Ottawa&limit=5 500 Internal Server Error\n

Possible Causes:

  1. Geocoding service down:
  2. All providers (Nominatim, ArcGIS, Photon) unavailable
  3. Network connectivity issue

  4. Invalid query:

  5. Query too short (< 2 characters)
  6. Special characters causing parsing errors

  7. Rate limiting:

  8. Too many requests to geocoding providers
  9. IP temporarily blocked

Solution:

  1. For service issues:
  2. Check geocoding service logs: docker compose logs api | grep geocoding
  3. Test Nominatim directly: curl \"https://nominatim.openstreetmap.org/search?q=Ottawa&format=json\"
  4. If down, wait 5 minutes and retry

  5. For invalid queries:

  6. Ensure at least 2 characters entered
  7. Try simpler query (e.g., \"Ottawa\" instead of \"Ottawa, ON, Canada\")

  8. For rate limiting:

  9. Wait 1 hour before retrying
  10. Use manual coordinate entry instead
  11. Consider adding Mapbox API key (higher rate limits)
"},{"location":"v2/frontend/pages/admin/map-settings-page/#qr-codes-not-showing-in-preview","title":"QR Codes Not Showing in Preview","text":"

Problem: Enter QR code URL in form, but QR code doesn't appear in preview.

Diagnosis:

Check QR code API endpoint:

curl \"http://localhost:4000/api/qr?text=https%3A%2F%2Fcmlite.org&size=200\" -o test-qr.png\n

Expected: PNG file downloaded

Actual: Error response

{\n  \"error\": \"Invalid size parameter\"\n}\n

Possible Causes:

  1. Invalid URL:
  2. QR code URL field contains invalid URL
  3. Special characters not URL-encoded

  4. QR API endpoint down:

  5. API container not running
  6. QR code generation service crashed

  7. Browser caching:

  8. Browser cached old QR code PNG
  9. Need to clear cache or force refresh

Solution:

  1. For invalid URLs:
  2. Ensure URL includes protocol: https:// not www.
  3. Test URL in browser: Click URL to verify it opens correctly
  4. Check for special characters: URL-encode if necessary

  5. For API issues:

  6. Check API logs: docker compose logs api | grep qr
  7. Restart API: docker compose restart api
  8. Test endpoint: curl \"http://localhost:4000/api/qr?text=test&size=200\"

  9. For caching:

  10. Hard refresh browser: Ctrl+Shift+R (Windows) or Cmd+Shift+R (Mac)
  11. Clear browser cache: Settings \u2192 Clear browsing data
  12. Add cache-busting param: &v=${Date.now()} to QR code URL
"},{"location":"v2/frontend/pages/admin/map-settings-page/#walk-sheet-prints-with-navigation","title":"Walk Sheet Prints with Navigation","text":"

Problem: Click \"Print\" button, but printed page includes navigation sidebar and header.

Diagnosis:

Check print preview (Ctrl+P or Cmd+P):

Expected: Only walk sheet visible

Actual: Full page (navigation, header, footer) visible

Possible Causes:

  1. Print CSS not applied:
  2. Browser not detecting print media query
  3. CSS @media print not working

  4. CSS specificity issue:

  5. Other stylesheets overriding print CSS
  6. !important flags not effective

  7. Browser print settings:

  8. \"Print backgrounds\" option disabled
  9. \"Headers and footers\" option enabled

Solution:

  1. For CSS issues (developer fix):
  2. Increase specificity: body * { visibility: hidden !important; }
  3. Use @page { margin: 0; } to remove default margins
  4. Test in multiple browsers (Chrome, Firefox, Safari)

  5. For browser settings:

  6. Enable \"Print backgrounds\" in print dialog
  7. Disable \"Headers and footers\" in print dialog
  8. Select \"None\" for margins

  9. Alternative (if CSS fails):

  10. Open walk sheet in new window: window.open('/walk-sheet-preview')
  11. Print from dedicated preview page (no navigation)
  12. Use \"Print to PDF\" and print PDF separately
"},{"location":"v2/frontend/pages/admin/map-settings-page/#coordinates-dont-update-map-center","title":"Coordinates Don't Update Map Center","text":"

Problem: Save new latitude/longitude in settings, but public map still shows old center.

Diagnosis:

Check public map settings fetch:

curl \"http://localhost:4000/api/map/settings\"\n

Expected: New coordinates returned

{\n  \"latitude\": \"45.4215\",\n  \"longitude\": \"-75.6972\"\n}\n

Actual: Old coordinates returned

{\n  \"latitude\": \"43.6532\",\n  \"longitude\": \"-79.3832\"\n}\n

Possible Causes:

  1. Settings not saved:
  2. Save button clicked but API request failed
  3. Error message shown but not noticed

  4. Database not updated:

  5. Database write failed (permissions issue)
  6. Transaction rolled back due to error

  7. Public map caching:

  8. Public map caching old settings in browser
  9. Need to clear cache or force refresh

Solution:

  1. For save issues:
  2. Check API logs: docker compose logs api | grep \"settings saved\"
  3. Retry save: Click \"Save Settings\" again
  4. Check for error messages: Look for red toast notification

  5. For database issues:

  6. Check database: docker compose exec v2-postgres psql -U postgres -d v2 -c \"SELECT * FROM \\\"MapSettings\\\"\"
  7. Verify coordinates match expected values
  8. If mismatch, manually update: UPDATE \"MapSettings\" SET latitude = '45.4215', longitude = '-75.6972'

  9. For caching:

  10. Hard refresh public map: Ctrl+Shift+R (Windows) or Cmd+Shift+R (Mac)
  11. Clear browser cache: Settings \u2192 Clear browsing data
  12. Check API response: Verify /api/map/settings returns new coordinates
"},{"location":"v2/frontend/pages/admin/map-settings-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/","title":"MiniQRPage","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#overview","title":"Overview","text":"

File: admin/src/pages/MiniQRPage.tsx

Route: /app/services/mini-qr

Role Requirements: Any authenticated user (uses authenticate middleware)

Purpose: Provides an embedded interface to the Mini QR code generator service via iframe. This page serves as a simple wrapper that embeds the external QR code generation service with online/offline status monitoring and mobile device detection.

Key Features: - Full-page iframe embed of Mini QR service - Service online/offline status monitoring - Mobile device detection with warning screen - Fullbleed layout (no padding in AppLayout) - Automatic service health checks

Layout: AppLayout with fullbleed (no content padding)

Dependencies: - Ant Design v5 (Alert, Spin, Typography, Result, Button, Grid) - react hooks (useState, useEffect)

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#1-service-status-monitoring","title":"1. Service Status Monitoring","text":"

Status Display: - Green checkmark with \"Service Online\" when Mini QR is accessible - Red X with \"Service Offline\" when Mini QR is not accessible - Loading spinner during status check

Auto-refresh: - Status checked on page load - No automatic periodic refresh (manual refresh via browser required)

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#2-mobile-device-detection","title":"2. Mobile Device Detection","text":"

Mobile Warning Screen: - Detects mobile devices using Grid.useBreakpoint() - Shows warning Result component on mobile - Recommends using desktop for better QR code generation experience - Provides link to open service in new tab

Breakpoint: !screens.md (screen width < 768px = mobile)

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#3-iframe-embedding","title":"3. Iframe Embedding","text":"

Fullbleed Layout: - No padding around iframe - 100% width and height - Seamless integration with AppLayout

Error Handling: - Shows error message if iframe fails to load - Provides troubleshooting guidance

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#accessing-mini-qr-service","title":"Accessing Mini QR Service","text":"
  1. Navigate to Mini QR:
  2. Click \"Services\" \u2192 \"Mini QR\" in sidebar
  3. Page loads with status check

  4. Check Service Status:

  5. Status indicator appears at top:
  6. Loading spinner shown during status check

  7. View on Desktop:

  8. If on desktop (screen width \u2265 768px):

  9. View on Mobile:

  10. If on mobile (screen width < 768px):

  11. Using Mini QR Service:

  12. Enter text or URL to encode
  13. Select QR code size/format
  14. Generate QR code
  15. Download QR code image

  16. Troubleshoot Offline Service:

  17. If service shows \"Offline\":
  18. Refresh page after fixing
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#main-component-structure","title":"Main Component Structure","text":"
const MiniQRPage: React.FC = () => {\n  // State\n  const [loading, setLoading] = useState(true);\n  const [online, setOnline] = useState(false);\n\n  // Responsive breakpoints\n  const screens = Grid.useBreakpoint();\n  const isMobile = !screens.md;\n\n  // Check service status on mount\n  useEffect(() => {\n    checkServiceStatus();\n  }, []);\n\n  const checkServiceStatus = async () => {\n    try {\n      setLoading(true);\n      const response = await api.get('/api/services/mini-qr/status');\n      setOnline(response.data.online);\n    } catch (error) {\n      console.error('Failed to check Mini QR status:', error);\n      setOnline(false);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  // Show mobile warning if on mobile device\n  if (isMobile) {\n    return (\n      <Result\n        icon={<MobileOutlined />}\n        title=\"Desktop Recommended\"\n        subTitle=\"Mini QR is best used on desktop for optimal QR code generation experience.\"\n        extra={\n          <Button\n            type=\"primary\"\n            href=\"http://qr.cmlite.org\"\n            target=\"_blank\"\n          >\n            Open in New Tab\n          </Button>\n        }\n      />\n    );\n  }\n\n  return (\n    <div style={{ height: '100%' }}>\n      {/* Status indicator */}\n      {loading ? (\n        <Spin />\n      ) : (\n        <Alert\n          type={online ? 'success' : 'error'}\n          message={online ? 'Service Online' : 'Service Offline'}\n          showIcon\n        />\n      )}\n\n      {/* Iframe embed */}\n      {online && !loading && (\n        <iframe\n          src=\"http://qr.cmlite.org\"\n          style={{\n            width: '100%',\n            height: 'calc(100% - 60px)',  // Subtract status bar height\n            border: 'none',\n          }}\n          title=\"Mini QR Code Generator\"\n        />\n      )}\n    </div>\n  );\n};\n
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  1. Alert - Service status indicator (success/error)
  2. Spin - Loading spinner during status check
  3. Result - Mobile warning screen with icon and message
  4. Button - \"Open in New Tab\" action button
  5. Typography.Text - Descriptive text (if needed)
  6. Grid.useBreakpoint() - Responsive breakpoint detection
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#iframe-configuration","title":"Iframe Configuration","text":"
<iframe\n  src=\"http://qr.cmlite.org\"  // Mini QR service URL (nginx proxied)\n  style={{\n    width: '100%',             // Full container width\n    height: 'calc(100% - 60px)',  // Full height minus status bar\n    border: 'none',            // No border for seamless integration\n  }}\n  title=\"Mini QR Code Generator\"  // Accessibility title\n  sandbox=\"allow-same-origin allow-scripts allow-forms\"  // Security sandbox\n/>\n

Sandbox Attributes: - allow-same-origin - Allows iframe to access cookies/localStorage - allow-scripts - Allows JavaScript execution - allow-forms - Allows form submission

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#local-component-state-usestate","title":"Local Component State (useState)","text":"

No Zustand stores used - All state managed locally with React hooks.

// Service status loading state\nconst [loading, setLoading] = useState(true);\n\n// Service online/offline state\nconst [online, setOnline] = useState(false);\n\n// Responsive breakpoint detection\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;  // Mobile if screen width < 768px\n
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#state-flow","title":"State Flow","text":"
  1. Component Mounts:
  2. checkServiceStatus() called in useEffect
  3. Sets loading to true
  4. Fetches service status via GET /api/services/mini-qr/status
  5. Sets online to true or false based on response
  6. Sets loading to false

  7. Service Online:

  8. online is true
  9. Alert shows \"Service Online\" (green)
  10. Iframe renders with Mini QR service embedded

  11. Service Offline:

  12. online is false
  13. Alert shows \"Service Offline\" (red)
  14. No iframe rendered (blank space below alert)

  15. Mobile Device:

  16. isMobile is true
  17. Component returns early with warning Result
  18. No status check, no iframe
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/services/mini-qr/status - Check Mini QR service health
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#api-client","title":"API Client","text":"
import { api } from '@/lib/api';\n\n// All requests use authenticated API client with automatic token refresh\n
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#example-api-call","title":"Example API Call","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#check-service-status","title":"Check Service Status","text":"
const checkServiceStatus = async () => {\n  try {\n    setLoading(true);\n\n    // Check service health\n    const response = await api.get('/api/services/mini-qr/status');\n\n    // Set online state based on response\n    setOnline(response.data.online);\n  } catch (error) {\n    console.error('Failed to check Mini QR status:', error);\n\n    // Treat any error as offline\n    setOnline(false);\n  } finally {\n    setLoading(false);\n  }\n};\n\nuseEffect(() => {\n  checkServiceStatus();\n}, []);\n

Response Format (Online):

{\n  \"online\": true,\n  \"url\": \"http://qr.cmlite.org\",\n  \"message\": \"Mini QR service is online\"\n}\n

Response Format (Offline):

{\n  \"online\": false,\n  \"message\": \"Mini QR service is offline\",\n  \"error\": \"Connection refused\"\n}\n

Error Handling: - Network errors (500, 503): Treated as offline - Timeout errors: Treated as offline - CORS errors: Treated as offline (service not accessible)

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#complete-component-implementation","title":"Complete Component Implementation","text":"
import React, { useState, useEffect } from 'react';\nimport { Alert, Spin, Typography, Result, Button, Grid } from 'antd';\nimport { MobileOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';\nimport { api } from '@/lib/api';\n\nconst { Title } = Typography;\n\nconst MiniQRPage: React.FC = () => {\n  const [loading, setLoading] = useState(true);\n  const [online, setOnline] = useState(false);\n\n  const screens = Grid.useBreakpoint();\n  const isMobile = !screens.md;\n\n  useEffect(() => {\n    checkServiceStatus();\n  }, []);\n\n  const checkServiceStatus = async () => {\n    try {\n      setLoading(true);\n      const response = await api.get('/api/services/mini-qr/status');\n      setOnline(response.data.online);\n    } catch (error) {\n      console.error('Failed to check Mini QR status:', error);\n      setOnline(false);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  // Show mobile warning on mobile devices\n  if (isMobile) {\n    return (\n      <Result\n        icon={<MobileOutlined style={{ fontSize: 72, color: '#faad14' }} />}\n        title=\"Desktop Recommended\"\n        subTitle=\"Mini QR is best used on desktop for optimal QR code generation experience. You can open the service in a new tab to use it on mobile.\"\n        extra={\n          <Button\n            type=\"primary\"\n            size=\"large\"\n            href=\"http://qr.cmlite.org\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n          >\n            Open in New Tab\n          </Button>\n        }\n      />\n    );\n  }\n\n  return (\n    <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>\n      {/* Status Bar */}\n      <div style={{ padding: '16px 0' }}>\n        {loading ? (\n          <div style={{ textAlign: 'center' }}>\n            <Spin tip=\"Checking service status...\" />\n          </div>\n        ) : (\n          <Alert\n            type={online ? 'success' : 'error'}\n            message={\n              online ? (\n                <>\n                  <CheckCircleOutlined style={{ marginRight: 8 }} />\n                  Service Online\n                </>\n              ) : (\n                <>\n                  <CloseCircleOutlined style={{ marginRight: 8 }} />\n                  Service Offline\n                </>\n              )\n            }\n            description={\n              online\n                ? 'Mini QR service is running and accessible'\n                : 'Mini QR service is currently unavailable. Please check Docker container status.'\n            }\n            showIcon\n            banner\n          />\n        )}\n      </div>\n\n      {/* Iframe Embed */}\n      {online && !loading && (\n        <iframe\n          src=\"http://qr.cmlite.org\"\n          style={{\n            width: '100%',\n            height: 'calc(100% - 80px)',  // Subtract status bar height\n            border: 'none',\n            flexGrow: 1,\n          }}\n          title=\"Mini QR Code Generator\"\n          sandbox=\"allow-same-origin allow-scripts allow-forms allow-downloads\"\n          loading=\"lazy\"\n        />\n      )}\n\n      {/* Offline Message */}\n      {!online && !loading && (\n        <div style={{ padding: 24, textAlign: 'center' }}>\n          <Title level={4}>Service Not Available</Title>\n          <Typography.Paragraph>\n            The Mini QR service is currently offline. Please contact your system administrator\n            or check the Docker container status.\n          </Typography.Paragraph>\n          <Button onClick={checkServiceStatus}>\n            Retry\n          </Button>\n        </div>\n      )}\n    </div>\n  );\n};\n\nexport default MiniQRPage;\n
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#mobile-detection-pattern","title":"Mobile Detection Pattern","text":"
import { Grid } from 'antd';\n\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;  // Mobile if screen width < 768px\n\nif (isMobile) {\n  return (\n    <Result\n      icon={<MobileOutlined />}\n      title=\"Desktop Recommended\"\n      subTitle=\"This service works best on desktop devices.\"\n      extra={\n        <Button type=\"primary\" href=\"<service-url>\" target=\"_blank\">\n          Open in New Tab\n        </Button>\n      }\n    />\n  );\n}\n

Breakpoint Values: - xs: < 576px (extra small) - sm: \u2265 576px (small) - md: \u2265 768px (medium) \u2190 Used for mobile detection - lg: \u2265 992px (large) - xl: \u2265 1200px (extra large) - xxl: \u2265 1600px (extra extra large)

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#fullbleed-layout-pattern","title":"Fullbleed Layout Pattern","text":"
// In App.tsx route configuration\n<Route\n  path=\"/app/services/mini-qr\"\n  element={<MiniQRPage />}\n/>\n\n// In MiniQRPage component\nconst MiniQRPage: React.FC = () => {\n  return (\n    <div style={{ height: '100%' }}>  {/* Full height container */}\n      <iframe\n        src=\"http://qr.cmlite.org\"\n        style={{\n          width: '100%',              // Full width\n          height: 'calc(100% - 60px)',  // Full height minus status bar\n          border: 'none',             // No border\n        }}\n      />\n    </div>\n  );\n};\n\n// AppLayout automatically applies fullbleed styling (no padding)\n// when route is detected as service page\n
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#service-status-check-with-error-handling","title":"Service Status Check with Error Handling","text":"
const checkServiceStatus = async () => {\n  try {\n    setLoading(true);\n\n    // Set timeout for status check (5 seconds)\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), 5000);\n\n    const response = await api.get('/api/services/mini-qr/status', {\n      signal: controller.signal,\n    });\n\n    clearTimeout(timeoutId);\n\n    // Check response\n    if (response.data.online) {\n      setOnline(true);\n    } else {\n      setOnline(false);\n      console.warn('Mini QR service reported as offline:', response.data.message);\n    }\n  } catch (error) {\n    // Handle different error types\n    if (error.name === 'AbortError') {\n      console.error('Mini QR status check timed out');\n    } else if (error.response) {\n      console.error('Mini QR status check failed with status:', error.response.status);\n    } else {\n      console.error('Mini QR status check failed:', error.message);\n    }\n\n    // Always treat errors as offline\n    setOnline(false);\n  } finally {\n    setLoading(false);\n  }\n};\n
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#1-lazy-iframe-loading","title":"1. Lazy Iframe Loading","text":"
<iframe\n  src=\"http://qr.cmlite.org\"\n  loading=\"lazy\"  // Defers iframe loading until near viewport\n  // ... other props\n/>\n

Benefit: Saves bandwidth and CPU by not loading iframe until needed. However, since iframe is typically in viewport immediately, this has minimal impact.

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#2-early-mobile-detection","title":"2. Early Mobile Detection","text":"
// Check mobile before any API calls or rendering\nif (isMobile) {\n  return <Result />;  // Render warning immediately, no API calls\n}\n

Benefit: Avoids unnecessary service status checks on mobile devices, saving API requests and improving page load time.

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#3-single-status-check","title":"3. Single Status Check","text":"
useEffect(() => {\n  checkServiceStatus();  // Only check once on mount\n}, []);  // Empty dependency array = run once\n

Benefit: Minimizes API requests. Status is checked once and cached. Manual refresh required to re-check (acceptable for service status).

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#4-abort-controller-for-timeout","title":"4. Abort Controller for Timeout","text":"
const controller = new AbortController();\nconst timeoutId = setTimeout(() => controller.abort(), 5000);\n\nconst response = await api.get('/api/services/mini-qr/status', {\n  signal: controller.signal,\n});\n\nclearTimeout(timeoutId);\n

Benefit: Prevents long-hanging requests if service is slow or unresponsive. Improves user experience by failing fast (5s timeout).

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#breakpoint-based-mobile-detection","title":"Breakpoint-Based Mobile Detection","text":"
const screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;  // Mobile if screen width < 768px\n

Responsive Behavior: - Desktop (\u2265 768px): Full iframe embed with status bar - Mobile (< 768px): Warning Result with \"Open in New Tab\" button

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#mobile-warning-screen","title":"Mobile Warning Screen","text":"
if (isMobile) {\n  return (\n    <Result\n      icon={<MobileOutlined style={{ fontSize: 72, color: '#faad14' }} />}\n      title=\"Desktop Recommended\"\n      subTitle=\"Mini QR is best used on desktop for optimal QR code generation experience.\"\n      extra={\n        <Button\n          type=\"primary\"\n          size=\"large\"\n          href=\"http://qr.cmlite.org\"\n          target=\"_blank\"\n        >\n          Open in New Tab\n        </Button>\n      }\n    />\n  );\n}\n

Why Mobile Warning? - QR code generation requires precise input (URLs, text) - Small mobile screens make QR code preview difficult - Download/save functionality better on desktop - Iframe scrolling awkward on mobile

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#iframe-height-calculation","title":"Iframe Height Calculation","text":"
<iframe\n  style={{\n    width: '100%',\n    height: 'calc(100% - 80px)',  // Full height minus status bar (80px)\n    border: 'none',\n  }}\n/>\n

Responsive Height: - Uses CSS calc() to subtract status bar height from 100% - Ensures iframe fills remaining vertical space - No fixed height, adapts to browser window size

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#keyboard-navigation","title":"Keyboard Navigation","text":"
  1. Tab Key:
  2. Focuses \"Open in New Tab\" button (mobile view)
  3. Enters iframe (desktop view)
  4. Navigates through iframe content (if iframe supports tab navigation)

  5. Enter Key:

  6. Activates \"Open in New Tab\" button
  7. Interacts with iframe elements

  8. Escape Key:

  9. No special behavior (iframe handles internally)
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#aria-labels","title":"ARIA Labels","text":"
<iframe\n  src=\"http://qr.cmlite.org\"\n  title=\"Mini QR Code Generator\"  // Screen reader announces iframe purpose\n  aria-label=\"Embedded Mini QR code generator service\"\n  role=\"application\"  // Indicates embedded application\n/>\n\n<Button\n  aria-label=\"Open Mini QR service in new browser tab\"\n  href=\"http://qr.cmlite.org\"\n  target=\"_blank\"\n>\n  Open in New Tab\n</Button>\n\n<Result\n  icon={<MobileOutlined aria-hidden=\"true\" />}  // Icon is decorative\n  title=\"Desktop Recommended\"  // Screen reader announces title\n  subTitle=\"Mini QR is best used on desktop...\"  // Screen reader announces subtitle\n/>\n
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#color-contrast","title":"Color Contrast","text":"

All text meets WCAG AA standards: - Success alert background: #f6ffed with text #52c41a (contrast ratio 4.5:1) - Error alert background: #fff2f0 with text #ff4d4f (contrast ratio 4.5:1) - Button text: White on #1890ff (contrast ratio 4.5:1)

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#screen-reader-support","title":"Screen Reader Support","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#problem-service-shows-offline-despite-container-running","title":"Problem: Service Shows \"Offline\" Despite Container Running","text":"

Symptoms: - Status bar shows \"Service Offline\" (red) - Mini QR Docker container is running (docker compose ps shows \"Up\") - Iframe does not load

Causes: 1. Nginx routing misconfiguration 2. Service listening on wrong port 3. Network connectivity issues 4. CORS policy blocking status check

Solutions:

  1. Verify Docker container status:

    docker compose ps mini-qr\n# Should show \"Up\" status\n\ndocker compose logs mini-qr\n# Check for error messages\n

  2. Check nginx routing:

  3. Open nginx/conf.d/services.conf
  4. Verify Mini QR proxy block exists:
    location /qr/ {\n  proxy_pass http://mini-qr:8089/;\n  proxy_set_header Host $host;\n  proxy_set_header X-Real-IP $remote_addr;\n}\n
  5. Restart nginx: docker compose restart nginx

  6. Test direct access:

  7. Open browser
  8. Navigate to http://localhost:8089 (direct container port)
  9. If accessible directly but not through nginx, routing issue
  10. If not accessible directly, service issue

  11. Check service health endpoint:

    curl http://localhost:8089/health\n# Should return 200 OK\n

  12. Verify API endpoint:

  13. Open browser DevTools (F12)
  14. Go to Network tab
  15. Refresh page
  16. Look for GET /api/services/mini-qr/status request
  17. Check response:

  18. Restart services:

    docker compose restart mini-qr nginx api\n

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#problem-iframe-not-loading-even-when-service-is-online","title":"Problem: Iframe Not Loading Even When Service is Online","text":"

Symptoms: - Status bar shows \"Service Online\" (green) - Iframe appears as blank white rectangle - No error messages in console

Causes: 1. CORS policy blocking iframe embedding 2. X-Frame-Options header preventing embedding 3. Content Security Policy (CSP) blocking iframe 4. Service URL incorrect

Solutions:

  1. Check browser console for errors:
  2. Open DevTools (F12)
  3. Go to Console tab
  4. Look for errors like:
  5. These indicate CORS/CSP blocking

  6. Verify X-Frame-Options header:

  7. Check nginx config for Mini QR:
    location /qr/ {\n  proxy_pass http://mini-qr:8089/;\n  # Remove or comment out X-Frame-Options\n  # add_header X-Frame-Options \"DENY\";\n}\n
  8. Or set to SAMEORIGIN to allow same-domain embedding:

    add_header X-Frame-Options \"SAMEORIGIN\";\n

  9. Check iframe sandbox attributes:

    <iframe\n  sandbox=\"allow-same-origin allow-scripts allow-forms allow-downloads\"\n  // Add more permissions if needed:\n  // allow-popups, allow-top-navigation, etc.\n/>\n

  10. Test iframe in isolation:

  11. Create simple HTML file:
    <!DOCTYPE html>\n<html>\n<body>\n  <iframe src=\"http://qr.cmlite.org\" width=\"800\" height=\"600\"></iframe>\n</body>\n</html>\n
  12. Open in browser
  13. If iframe works here but not in React app, React-specific issue
  14. If iframe doesn't work here either, service configuration issue

  15. Verify service URL:

  16. Check iframe src attribute in code
  17. Should be http://qr.cmlite.org (nginx proxied)
  18. Try direct URL: http://localhost:8089 (for testing only)
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#problem-mobile-warning-shows-on-desktop","title":"Problem: Mobile Warning Shows on Desktop","text":"

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:
  2. Press Ctrl+0 (Windows/Linux) or Cmd+0 (Mac) to reset zoom to 100%
  3. Refresh page

  4. Maximize browser window:

  5. Click maximize button or press F11 for fullscreen
  6. Ensure window width > 768px
  7. Refresh page

  8. Close DevTools or dock to bottom:

  9. If DevTools open in side-by-side mode, window width reduced
  10. Close DevTools (F12) or dock to bottom
  11. Refresh page

  12. Check breakpoint detection:

  13. Open browser console (F12)
  14. Type: window.innerWidth
  15. If < 768, window too narrow
  16. Resize window wider and refresh

  17. Clear browser cache:

  18. Hard refresh: Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (Mac)
  19. Or clear browser cache entirely
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#problem-retry-button-does-nothing","title":"Problem: \"Retry\" Button Does Nothing","text":"

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:
  2. Service may need time to start
  3. Wait 30-60 seconds
  4. Click \"Retry\" again

  5. Check Docker containers:

    docker compose ps\n# Verify mini-qr, nginx, api all show \"Up\"\n\ndocker compose logs mini-qr\n# Check for startup errors\n

  6. Restart services:

    docker compose restart mini-qr nginx api\n# Wait 30 seconds for services to fully start\n# Refresh page and click \"Retry\"\n

  7. Check network connectivity:

    curl http://localhost:8089/health\n# Should return 200 OK\n\ncurl http://localhost:4000/api/services/mini-qr/status\n# Should return {\"online\": true}\n

  8. Hard refresh page:

  9. Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (Mac)
  10. Forces fresh status check
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#problem-iframe-content-not-responsive","title":"Problem: Iframe Content Not Responsive","text":"

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:
  2. Inspect iframe element in DevTools
  3. Verify width: 100% applied
  4. Verify parent container has sufficient width

  5. Remove iframe sandbox (temporarily):

    <iframe\n  src=\"http://qr.cmlite.org\"\n  // Remove sandbox for testing\n  // sandbox=\"allow-same-origin allow-scripts allow-forms\"\n/>\n

  6. If content becomes responsive without sandbox, sandbox is blocking responsive behavior
  7. Add back sandbox with minimal restrictions

  8. Use viewport meta tag in service:

  9. If Mini QR is custom service, add to its HTML:

    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n

  10. Scale iframe content:

    <iframe\n  style={{\n    width: '100%',\n    height: 'calc(100% - 80px)',\n    border: 'none',\n    transform: 'scale(0.9)',  // Scale down content\n    transformOrigin: 'top left',\n  }}\n/>\n

  11. Open in new tab:

  12. If iframe content truly not responsive, use \"Open in New Tab\" approach
  13. Remove iframe, show button like mobile view
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#backend-documentation","title":"Backend Documentation","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#frontend-documentation","title":"Frontend Documentation","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#feature-documentation","title":"Feature Documentation","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#api-documentation","title":"API Documentation","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#deployment-documentation","title":"Deployment Documentation","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#development-documentation","title":"Development Documentation","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/","title":"MkDocsSettingsPage","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#overview","title":"Overview","text":"

File: admin/src/pages/MkDocsSettingsPage.tsx

Route: /app/docs/settings

Role Requirements: SUPER_ADMIN only

Purpose: Comprehensive MkDocs configuration editor providing four different interfaces for managing documentation site settings, navigation structure, raw YAML configuration, and static site builds. This page serves as the central control panel for the documentation system.

Key Features: - Four-tab interface (Settings, Navigation, YAML Editor, Build) - Visual form-based settings editor with validation - Interactive drag-and-drop navigation tree builder - Raw YAML editor with syntax highlighting and keyboard shortcuts - Orphaned file detection and management - Campaign link integration - Static site build triggering - Mobile-responsive YAML editor warning

Layout: Full AppLayout with sidebar navigation

Dependencies: - Ant Design v5 (Form, Input, Switch, Tree, Modal, Button, Card, Tabs, Alert, Typography, message) - @monaco-editor/react for YAML editing - yaml library for parsing/stringifying - dayjs for date formatting - react-beautiful-dnd for drag-and-drop (tree operations)

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#1-settings-tab-form-based-configuration","title":"1. Settings Tab (Form-Based Configuration)","text":"

Form Fields: - Site Name (Input) - Documentation site title - Site URL (Input.TextArea) - Public site URL - Site Description (Input.TextArea) - Site meta description - Site Author (Input) - Author attribution - Copyright (Input) - Copyright notice - Repo URL (Input) - GitHub repository URL - Repo Name (Input) - Repository display name - Edit URI (Input) - Edit page URI pattern - Theme Name (Input) - MkDocs theme selection - Primary Color (Input) - Theme primary color (hex) - Accent Color (Input) - Theme accent color (hex) - Features (dynamic tag input) - Theme feature toggles - Plugins (dynamic tag input) - MkDocs plugins - Markdown Extensions (dynamic tag input) - Markdown extension list - Extra CSS (dynamic tag input) - Additional CSS file paths - Extra JavaScript (dynamic tag input) - Additional JS file paths

Validation: - URL fields validated for proper format - Color fields validated for hex format (#RRGGBB) - Required fields enforced (site name, theme)

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#2-navigation-tab-visual-tree-builder","title":"2. Navigation Tab (Visual Tree Builder)","text":"

Navigation Tree: - Hierarchical tree view of site navigation structure - Drag-and-drop reordering (via react-beautiful-dnd) - Section/page distinction (folder icons vs file icons) - Expandable/collapsible sections - Edit titles inline - Add/remove navigation items - Orphaned file detection

Orphaned Files Section: - Separate panel showing markdown files not in navigation - Drag files from orphaned list to navigation tree - One-click \"Add to Navigation\" buttons - File path display with relative paths

Campaign Link Integration: - Dedicated \"Add Campaign Link\" button - Fetches active campaigns via API - Generates campaign page links automatically - Inserts into navigation tree

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#3-yaml-editor-tab-raw-configuration","title":"3. YAML Editor Tab (Raw Configuration)","text":"

Monaco Editor: - Full YAML syntax highlighting - 600px height - Auto-formatting on save - Keyboard shortcuts: - Ctrl+S / Cmd+S - Save changes - Ctrl+F - Find - Ctrl+H - Find and replace

Custom YAML Parsing: - Handles Python tags (e.g., !relative $config_dir/includes) - Preserves tag structure during parse/stringify - Error handling for invalid YAML

Mobile Warning: - Detects mobile devices with Grid.useBreakpoint() - Shows warning Alert if !screens.md - Recommends using desktop for YAML editing

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#4-build-tab-static-site-generation","title":"4. Build Tab (Static Site Generation)","text":"

Build Trigger: - Manual build button - Builds MkDocs static site - Shows success/error messages - Displays build timestamp (last successful build)

Build Status: - Last build date (formatted with dayjs) - Build in progress indicator - Build error display

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#editing-basic-settings","title":"Editing Basic Settings","text":"
  1. Navigate to MkDocs Settings:
  2. Click \"Documentation\" \u2192 \"MkDocs Settings\" in sidebar
  3. Page loads with 4 tabs at top

  4. Select Settings Tab:

  5. First tab is active by default
  6. Shows form with ~15 fields

  7. Edit Site Information:

  8. Update site name: \"Changemaker Lite Documentation\"
  9. Update site description
  10. Set author name
  11. Configure copyright notice

  12. Configure Repository Links:

  13. Enter GitHub repository URL
  14. Set repository name (e.g., \"changemaker-lite\")
  15. Configure edit URI pattern (e.g., \"edit/main/docs/\")

  16. Customize Theme:

  17. Select theme name (e.g., \"material\")
  18. Set primary color (hex, e.g., \"#1976d2\")
  19. Set accent color (hex, e.g., \"#f50057\")
  20. Add theme features as tags:

  21. Configure Plugins:

  22. Add plugins as tags:
  23. Click X on tags to remove

  24. Add Markdown Extensions:

  25. Add extensions as tags:

  26. Save Settings:

  27. Click \"Save Changes\" button at bottom
  28. Success message appears
  29. Settings persisted to database
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#building-navigation-structure","title":"Building Navigation Structure","text":"
  1. Navigate to Navigation Tab:
  2. Click \"Navigation\" tab (second tab)
  3. Tree view loads with current navigation

  4. Understand Tree Structure:

  5. Sections: Items with children (folder icon)
  6. Pages: Leaf items (file icon)
  7. Expandable: Click arrow to expand/collapse sections

  8. Reorder Items (Drag and Drop):

  9. Click and hold on navigation item
  10. Drag to new position
  11. Drop to reorder
  12. Changes saved automatically

  13. Add New Section:

  14. Click \"Add Section\" button
  15. Modal appears
  16. Enter section title (e.g., \"API Reference\")
  17. Click \"Create\"
  18. New section appears in tree

  19. Add New Page:

  20. Click \"Add Page\" button
  21. Modal appears
  22. Enter page title (e.g., \"Authentication API\")
  23. Enter file path (e.g., \"api/authentication.md\")
  24. Click \"Create\"
  25. New page appears in tree

  26. Edit Navigation Item:

  27. Click \"Edit\" icon next to item
  28. Modal appears with title field
  29. Update title
  30. Click \"Save\"

  31. Remove Navigation Item:

  32. Click \"Delete\" icon next to item
  33. Confirmation modal appears
  34. Click \"Confirm\" to remove
  35. Item removed from navigation (file remains on disk)

  36. Handle Orphaned Files:

  37. Scroll to \"Orphaned Files\" section at bottom
  38. See list of markdown files not in navigation
  39. Two options per file:
  40. File moves from orphaned list to navigation tree

  41. Add Campaign Links:

  42. Click \"Add Campaign Link\" button
  43. Modal appears with campaign dropdown
  44. Select active campaign from list
  45. Click \"Add\"
  46. Campaign link inserted into navigation with auto-generated path

  47. Save Navigation:

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#advanced-yaml-editing","title":"Advanced YAML Editing","text":"
  1. Navigate to YAML Editor Tab:
  2. Click \"YAML Editor\" tab (third tab)
  3. Monaco editor loads with current mkdocs.yml content

  4. Check Mobile Warning:

  5. If on mobile device, warning Alert shows:

  6. Edit Raw YAML:

  7. Click in editor to position cursor
  8. Edit YAML directly:

    site_name: Changemaker Lite Documentation\nsite_url: https://docs.cmlite.org\ntheme:\n  name: material\n  palette:\n    primary: blue\n    accent: pink\n  features:\n    - navigation.tabs\n    - toc.integrate\n

  9. Use Keyboard Shortcuts:

  10. Ctrl+S (Cmd+S on Mac): Save changes immediately
  11. Ctrl+F: Open find dialog
  12. Ctrl+H: Open find and replace dialog
  13. Ctrl+Z: Undo
  14. Ctrl+Y: Redo

  15. Handle Python Tags:

  16. Editor preserves custom Python tags:
    nav:\n  - Home: !relative $config_dir/index.md\n
  17. Tags preserved during parse/stringify cycle

  18. Save YAML:

  19. Click \"Save Changes\" button below editor
  20. OR press Ctrl+S keyboard shortcut
  21. Success message appears
  22. YAML validated and saved to database

  23. Handle YAML Errors:

  24. If YAML is invalid, error message appears:
  25. Fix syntax and try saving again
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#building-static-site","title":"Building Static Site","text":"
  1. Navigate to Build Tab:
  2. Click \"Build\" tab (fourth tab)
  3. Build status card loads

  4. Check Last Build:

  5. See \"Last Build\" timestamp
  6. Example: \"Built 2 hours ago\"

  7. Trigger New Build:

  8. Click \"Build Site\" button
  9. Button shows loading spinner
  10. Build starts in background

  11. Monitor Build Progress:

  12. \"Building...\" status appears
  13. Wait 10-30 seconds for build to complete

  14. View Build Result:

  15. Success: Green checkmark, \"Build completed successfully\"
  16. Error: Red X, error message displayed

  17. Access Built Site:

  18. Built site served at http://localhost:4001 (production)
  19. Or http://localhost:4003 (dev server)
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#main-component-structure","title":"Main Component Structure","text":"
const MkDocsSettingsPage: React.FC = () => {\n  // State\n  const [activeTab, setActiveTab] = useState<'settings' | 'navigation' | 'yaml' | 'build'>('settings');\n  const [config, setConfig] = useState<MkDocsConfig | null>(null);\n  const [navStructure, setNavStructure] = useState<NavItem[]>([]);\n  const [orphanedFiles, setOrphanedFiles] = useState<string[]>([]);\n  const [yamlContent, setYamlContent] = useState<string>('');\n  const [loading, setLoading] = useState(false);\n  const [saving, setSaving] = useState(false);\n  const [building, setBuilding] = useState(false);\n  const [lastBuild, setLastBuild] = useState<string | null>(null);\n\n  // Form instance for Settings tab\n  const [form] = Form.useForm();\n\n  // Responsive breakpoints\n  const screens = Grid.useBreakpoint();\n  const isMobile = !screens.md;\n\n  // Load configuration on mount\n  useEffect(() => {\n    loadConfig();\n  }, []);\n\n  return (\n    <div>\n      <Typography.Title level={2}>MkDocs Settings</Typography.Title>\n\n      <Tabs activeKey={activeTab} onChange={setActiveTab}>\n        <Tabs.TabPane tab=\"Settings\" key=\"settings\">\n          {/* Form-based settings editor */}\n        </Tabs.TabPane>\n\n        <Tabs.TabPane tab=\"Navigation\" key=\"navigation\">\n          {/* Tree-based navigation builder */}\n        </Tabs.TabPane>\n\n        <Tabs.TabPane tab=\"YAML Editor\" key=\"yaml\">\n          {/* Monaco YAML editor */}\n        </Tabs.TabPane>\n\n        <Tabs.TabPane tab=\"Build\" key=\"build\">\n          {/* Static site build trigger */}\n        </Tabs.TabPane>\n      </Tabs>\n    </div>\n  );\n};\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  1. Tabs - Four-tab interface switcher
  2. Form - Settings form with validation
  3. Input / Input.TextArea - Text field inputs
  4. Switch - Boolean toggles
  5. Select - Dropdown selections (used in modals)
  6. Tree - Hierarchical navigation tree view
  7. Button - Action buttons (Save, Add, Delete, Build)
  8. Modal - Dialogs for add/edit operations
  9. Card - Content containers for each tab
  10. Alert - Warning messages (mobile YAML editor, orphaned files)
  11. Typography.Title - Page heading
  12. Typography.Text - Descriptive text
  13. message - Toast notifications (save success/error)
  14. Space - Component spacing
  15. Divider - Visual separators
  16. Tag - Closable tags for arrays (features, plugins, etc.)
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#monaco-editor-configuration","title":"Monaco Editor Configuration","text":"
<Editor\n  height=\"600px\"\n  defaultLanguage=\"yaml\"\n  value={yamlContent}\n  onChange={(value) => setYamlContent(value || '')}\n  theme=\"vs-dark\"\n  options={{\n    minimap: { enabled: false },\n    fontSize: 14,\n    wordWrap: 'on',\n    automaticLayout: true,\n    scrollBeyondLastLine: false,\n    renderWhitespace: 'selection',\n  }}\n  onMount={(editor) => {\n    // Register Ctrl+S keyboard shortcut\n    editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {\n      handleSaveYAML();\n    });\n  }}\n/>\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#navigation-tree-structure","title":"Navigation Tree Structure","text":"
interface NavItem {\n  key: string;\n  title: string;\n  children?: NavItem[];\n  file?: string;  // File path for pages (leaves)\n}\n\n// Example navigation structure\nconst navStructure: NavItem[] = [\n  {\n    key: '1',\n    title: 'Getting Started',\n    children: [\n      { key: '1-1', title: 'Installation', file: 'getting-started/installation.md' },\n      { key: '1-2', title: 'Quick Start', file: 'getting-started/quick-start.md' },\n    ],\n  },\n  {\n    key: '2',\n    title: 'API Reference',\n    children: [\n      { key: '2-1', title: 'Authentication', file: 'api/authentication.md' },\n      { key: '2-2', title: 'Users', file: 'api/users.md' },\n    ],\n  },\n];\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#custom-yaml-parser-python-tag-handling","title":"Custom YAML Parser (Python Tag Handling)","text":"
import yaml from 'yaml';\n\n// Custom YAML parser that preserves Python tags\nconst parseYAML = (yamlString: string): any => {\n  try {\n    // Parse with yaml library (supports tags)\n    const parsed = yaml.parse(yamlString);\n    return parsed;\n  } catch (error) {\n    console.error('YAML parse error:', error);\n    throw new Error('Invalid YAML syntax');\n  }\n};\n\n// Custom YAML stringifier\nconst stringifyYAML = (obj: any): string => {\n  try {\n    return yaml.stringify(obj, {\n      indent: 2,\n      lineWidth: 0,  // No line wrapping\n    });\n  } catch (error) {\n    console.error('YAML stringify error:', error);\n    throw new Error('Failed to stringify YAML');\n  }\n};\n\n// Example: Parsing YAML with Python tags\nconst yamlWithTags = `\nnav:\n  - Home: !relative $config_dir/index.md\n  - API: !relative $config_dir/api/index.md\n`;\n\nconst parsed = parseYAML(yamlWithTags);\n// Tags preserved as special objects\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#local-component-state-usestate","title":"Local Component State (useState)","text":"

No Zustand stores used - All state managed locally with React hooks.

// Active tab state\nconst [activeTab, setActiveTab] = useState<'settings' | 'navigation' | 'yaml' | 'build'>('settings');\n\n// Configuration state (loaded from API)\nconst [config, setConfig] = useState<MkDocsConfig | null>(null);\n\n// Navigation structure state\nconst [navStructure, setNavStructure] = useState<NavItem[]>([]);\n\n// Orphaned files state\nconst [orphanedFiles, setOrphanedFiles] = useState<string[]>([]);\n\n// YAML editor content state\nconst [yamlContent, setYamlContent] = useState<string>('');\n\n// Loading states\nconst [loading, setLoading] = useState(false);      // Initial load\nconst [saving, setSaving] = useState(false);        // Save operation\nconst [building, setBuilding] = useState(false);    // Build operation\n\n// Last build timestamp\nconst [lastBuild, setLastBuild] = useState<string | null>(null);\n\n// Form instance (Ant Design)\nconst [form] = Form.useForm();\n\n// Responsive breakpoints\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#state-flow","title":"State Flow","text":"
  1. Component Mounts:
  2. loadConfig() called in useEffect
  3. Fetches MkDocs config via GET /api/docs/config
  4. Sets config, navStructure, orphanedFiles, yamlContent
  5. Sets form field values

  6. User Edits Settings Tab:

  7. Form fields update form state (Ant Design managed)
  8. Click \"Save Changes\" \u2192 handleSaveSettings()
  9. Gets values from form.getFieldsValue()
  10. Sends PUT /api/docs/config with updated config
  11. Re-fetches config on success

  12. User Edits Navigation Tab:

  13. Drag-and-drop updates navStructure state
  14. Add/edit/delete modals update navStructure
  15. Click \"Save Navigation\" \u2192 handleSaveNavigation()
  16. Sends PUT /api/docs/config with updated nav structure
  17. Re-fetches config on success

  18. User Edits YAML Tab:

  19. Monaco editor updates yamlContent state on change
  20. Click \"Save Changes\" or Ctrl+S \u2192 handleSaveYAML()
  21. Parses YAML to validate
  22. Sends PUT /api/docs/config with parsed YAML object
  23. Re-fetches config on success

  24. User Triggers Build:

  25. Click \"Build Site\" \u2192 handleBuild()
  26. Sets building to true
  27. Sends POST /api/docs/build
  28. Sets building to false on completion
  29. Updates lastBuild timestamp
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/docs/config - Fetch MkDocs configuration
  2. PUT /api/docs/config - Update MkDocs configuration
  3. POST /api/docs/build - Trigger static site build
  4. GET /api/influence/campaigns - Fetch active campaigns (for campaign link insertion)
  5. GET /api/docs/orphaned-files - Fetch markdown files not in navigation
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#api-client","title":"API Client","text":"
import { api } from '@/lib/api';\n\n// All requests use authenticated API client with automatic token refresh\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#example-api-calls","title":"Example API Calls","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#1-load-configuration","title":"1. Load Configuration","text":"
const loadConfig = async () => {\n  try {\n    setLoading(true);\n    const response = await api.get('/api/docs/config');\n    const configData = response.data.data;\n\n    // Set configuration state\n    setConfig(configData);\n\n    // Set navigation structure\n    setNavStructure(configData.nav || []);\n\n    // Set orphaned files\n    setOrphanedFiles(configData.orphanedFiles || []);\n\n    // Set YAML content\n    const yamlString = stringifyYAML(configData);\n    setYamlContent(yamlString);\n\n    // Populate form fields\n    form.setFieldsValue({\n      site_name: configData.site_name,\n      site_url: configData.site_url,\n      site_description: configData.site_description,\n      site_author: configData.site_author,\n      copyright: configData.copyright,\n      repo_url: configData.repo_url,\n      repo_name: configData.repo_name,\n      edit_uri: configData.edit_uri,\n      theme_name: configData.theme?.name,\n      theme_primary: configData.theme?.palette?.primary,\n      theme_accent: configData.theme?.palette?.accent,\n      theme_features: configData.theme?.features || [],\n      plugins: configData.plugins || [],\n      markdown_extensions: configData.markdown_extensions || [],\n      extra_css: configData.extra_css || [],\n      extra_javascript: configData.extra_javascript || [],\n    });\n\n    // Set last build timestamp\n    setLastBuild(configData.last_build);\n  } catch (error) {\n    message.error('Failed to load MkDocs configuration');\n    console.error('Load config error:', error);\n  } finally {\n    setLoading(false);\n  }\n};\n

Response Format:

{\n  \"success\": true,\n  \"data\": {\n    \"site_name\": \"Changemaker Lite Documentation\",\n    \"site_url\": \"https://docs.cmlite.org\",\n    \"site_description\": \"Comprehensive documentation for Changemaker Lite platform\",\n    \"site_author\": \"Changemaker Team\",\n    \"copyright\": \"Copyright &copy; 2025 Changemaker\",\n    \"repo_url\": \"https://github.com/example/changemaker-lite\",\n    \"repo_name\": \"changemaker-lite\",\n    \"edit_uri\": \"edit/main/docs/\",\n    \"theme\": {\n      \"name\": \"material\",\n      \"palette\": {\n        \"primary\": \"blue\",\n        \"accent\": \"pink\"\n      },\n      \"features\": [\n        \"navigation.tabs\",\n        \"navigation.sections\",\n        \"toc.integrate\"\n      ]\n    },\n    \"plugins\": [\"search\", \"minify\"],\n    \"markdown_extensions\": [\"admonition\", \"pymdownx.highlight\"],\n    \"extra_css\": [\"stylesheets/extra.css\"],\n    \"extra_javascript\": [\"javascripts/extra.js\"],\n    \"nav\": [\n      {\n        \"key\": \"1\",\n        \"title\": \"Home\",\n        \"file\": \"index.md\"\n      },\n      {\n        \"key\": \"2\",\n        \"title\": \"Getting Started\",\n        \"children\": [\n          {\n            \"key\": \"2-1\",\n            \"title\": \"Installation\",\n            \"file\": \"getting-started/installation.md\"\n          }\n        ]\n      }\n    ],\n    \"orphanedFiles\": [\"changelog.md\", \"contributing.md\"],\n    \"last_build\": \"2025-02-11T10:30:00Z\"\n  }\n}\n

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#2-save-settings-form-based","title":"2. Save Settings (Form-Based)","text":"
const handleSaveSettings = async () => {\n  try {\n    setSaving(true);\n\n    // Get form values\n    const values = form.getFieldsValue();\n\n    // Construct updated config\n    const updatedConfig = {\n      ...config,\n      site_name: values.site_name,\n      site_url: values.site_url,\n      site_description: values.site_description,\n      site_author: values.site_author,\n      copyright: values.copyright,\n      repo_url: values.repo_url,\n      repo_name: values.repo_name,\n      edit_uri: values.edit_uri,\n      theme: {\n        ...config?.theme,\n        name: values.theme_name,\n        palette: {\n          primary: values.theme_primary,\n          accent: values.theme_accent,\n        },\n        features: values.theme_features,\n      },\n      plugins: values.plugins,\n      markdown_extensions: values.markdown_extensions,\n      extra_css: values.extra_css,\n      extra_javascript: values.extra_javascript,\n    };\n\n    // Send update request\n    await api.put('/api/docs/config', updatedConfig);\n\n    message.success('Settings saved successfully');\n\n    // Reload configuration\n    await loadConfig();\n  } catch (error) {\n    message.error('Failed to save settings');\n    console.error('Save settings error:', error);\n  } finally {\n    setSaving(false);\n  }\n};\n

Request Payload:

{\n  \"site_name\": \"Changemaker Lite Documentation\",\n  \"site_url\": \"https://docs.cmlite.org\",\n  \"theme\": {\n    \"name\": \"material\",\n    \"palette\": {\n      \"primary\": \"blue\",\n      \"accent\": \"pink\"\n    },\n    \"features\": [\"navigation.tabs\", \"toc.integrate\"]\n  },\n  \"plugins\": [\"search\", \"minify\"],\n  \"markdown_extensions\": [\"admonition\"]\n}\n

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#3-save-navigation","title":"3. Save Navigation","text":"
const handleSaveNavigation = async () => {\n  try {\n    setSaving(true);\n\n    // Construct updated config with new navigation\n    const updatedConfig = {\n      ...config,\n      nav: navStructure,\n    };\n\n    // Send update request\n    await api.put('/api/docs/config', updatedConfig);\n\n    message.success('Navigation saved successfully');\n\n    // Reload configuration\n    await loadConfig();\n  } catch (error) {\n    message.error('Failed to save navigation');\n    console.error('Save navigation error:', error);\n  } finally {\n    setSaving(false);\n  }\n};\n

Request Payload:

{\n  \"nav\": [\n    {\n      \"key\": \"1\",\n      \"title\": \"Home\",\n      \"file\": \"index.md\"\n    },\n    {\n      \"key\": \"2\",\n      \"title\": \"Getting Started\",\n      \"children\": [\n        {\n          \"key\": \"2-1\",\n          \"title\": \"Installation\",\n          \"file\": \"getting-started/installation.md\"\n        },\n        {\n          \"key\": \"2-2\",\n          \"title\": \"Quick Start\",\n          \"file\": \"getting-started/quick-start.md\"\n        }\n      ]\n    }\n  ]\n}\n

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#4-save-yaml","title":"4. Save YAML","text":"
const handleSaveYAML = async () => {\n  try {\n    setSaving(true);\n\n    // Parse YAML to validate and convert to object\n    const parsedConfig = parseYAML(yamlContent);\n\n    // Send update request\n    await api.put('/api/docs/config', parsedConfig);\n\n    message.success('YAML saved successfully');\n\n    // Reload configuration\n    await loadConfig();\n  } catch (error) {\n    if (error.message === 'Invalid YAML syntax') {\n      message.error('Invalid YAML syntax. Please check and try again.');\n    } else {\n      message.error('Failed to save YAML');\n    }\n    console.error('Save YAML error:', error);\n  } finally {\n    setSaving(false);\n  }\n};\n

Request Payload: (Parsed YAML object)

{\n  \"site_name\": \"Changemaker Lite Documentation\",\n  \"theme\": {\n    \"name\": \"material\"\n  },\n  \"nav\": [\n    {\n      \"Home\": \"index.md\"\n    }\n  ]\n}\n

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#5-build-site","title":"5. Build Site","text":"
const handleBuild = async () => {\n  try {\n    setBuilding(true);\n\n    // Trigger build\n    await api.post('/api/docs/build');\n\n    message.success('Site built successfully');\n\n    // Reload configuration to get updated last_build timestamp\n    await loadConfig();\n  } catch (error) {\n    message.error('Failed to build site');\n    console.error('Build error:', error);\n  } finally {\n    setBuilding(false);\n  }\n};\n

Response Format:

{\n  \"success\": true,\n  \"message\": \"Site built successfully\",\n  \"data\": {\n    \"build_time\": \"2025-02-11T10:35:00Z\",\n    \"output_dir\": \"/app/mkdocs/site\"\n  }\n}\n

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#6-fetch-active-campaigns-for-link-insertion","title":"6. Fetch Active Campaigns (for link insertion)","text":"
const fetchCampaigns = async () => {\n  try {\n    const response = await api.get('/api/influence/campaigns', {\n      params: {\n        status: 'active',\n        limit: 100,\n      },\n    });\n\n    return response.data.data.campaigns;\n  } catch (error) {\n    console.error('Fetch campaigns error:', error);\n    return [];\n  }\n};\n\nconst handleAddCampaignLink = async () => {\n  // Fetch campaigns\n  const campaigns = await fetchCampaigns();\n\n  // Show modal with campaign dropdown\n  Modal.confirm({\n    title: 'Add Campaign Link',\n    content: (\n      <Select\n        placeholder=\"Select campaign\"\n        options={campaigns.map(c => ({\n          label: c.title,\n          value: c.id,\n        }))}\n        onChange={(campaignId) => {\n          // Find selected campaign\n          const campaign = campaigns.find(c => c.id === campaignId);\n\n          // Generate navigation item\n          const navItem: NavItem = {\n            key: `campaign-${campaignId}`,\n            title: campaign.title,\n            file: `campaigns/${campaign.slug}.md`,\n          };\n\n          // Add to navigation structure\n          setNavStructure([...navStructure, navItem]);\n        }}\n      />\n    ),\n  });\n};\n

Response Format:

{\n  \"success\": true,\n  \"data\": {\n    \"campaigns\": [\n      {\n        \"id\": 1,\n        \"title\": \"Climate Action Now\",\n        \"slug\": \"climate-action-now\",\n        \"status\": \"active\"\n      },\n      {\n        \"id\": 2,\n        \"title\": \"Education Funding\",\n        \"slug\": \"education-funding\",\n        \"status\": \"active\"\n      }\n    ],\n    \"pagination\": {\n      \"page\": 1,\n      \"limit\": 100,\n      \"total\": 2\n    }\n  }\n}\n

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#7-fetch-orphaned-files","title":"7. Fetch Orphaned Files","text":"
const fetchOrphanedFiles = async () => {\n  try {\n    const response = await api.get('/api/docs/orphaned-files');\n\n    setOrphanedFiles(response.data.data.files);\n  } catch (error) {\n    console.error('Fetch orphaned files error:', error);\n  }\n};\n

Response Format:

{\n  \"success\": true,\n  \"data\": {\n    \"files\": [\n      \"changelog.md\",\n      \"contributing.md\",\n      \"troubleshooting/common-issues.md\"\n    ]\n  }\n}\n

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#complete-loadconfig-implementation","title":"Complete loadConfig Implementation","text":"
const loadConfig = useCallback(async () => {\n  try {\n    setLoading(true);\n    const response = await api.get('/api/docs/config');\n    const configData = response.data.data;\n\n    // Set all state from API response\n    setConfig(configData);\n    setNavStructure(configData.nav || []);\n    setOrphanedFiles(configData.orphanedFiles || []);\n    setYamlContent(stringifyYAML(configData));\n    setLastBuild(configData.last_build);\n\n    // Populate form fields\n    form.setFieldsValue({\n      site_name: configData.site_name,\n      site_url: configData.site_url,\n      site_description: configData.site_description,\n      site_author: configData.site_author,\n      copyright: configData.copyright,\n      repo_url: configData.repo_url,\n      repo_name: configData.repo_name,\n      edit_uri: configData.edit_uri,\n      theme_name: configData.theme?.name,\n      theme_primary: configData.theme?.palette?.primary,\n      theme_accent: configData.theme?.palette?.accent,\n      theme_features: configData.theme?.features || [],\n      plugins: configData.plugins || [],\n      markdown_extensions: configData.markdown_extensions || [],\n      extra_css: configData.extra_css || [],\n      extra_javascript: configData.extra_javascript || [],\n    });\n  } catch (error) {\n    message.error('Failed to load MkDocs configuration');\n    console.error('Load config error:', error);\n  } finally {\n    setLoading(false);\n  }\n}, [form]);\n\nuseEffect(() => {\n  loadConfig();\n}, [loadConfig]);\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#settings-tab-form-rendering","title":"Settings Tab Form Rendering","text":"
<Form\n  form={form}\n  layout=\"vertical\"\n  onFinish={handleSaveSettings}\n>\n  <Form.Item\n    label=\"Site Name\"\n    name=\"site_name\"\n    rules={[{ required: true, message: 'Site name is required' }]}\n  >\n    <Input placeholder=\"e.g., Changemaker Lite Documentation\" />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Site URL\"\n    name=\"site_url\"\n    rules={[\n      { required: true, message: 'Site URL is required' },\n      { type: 'url', message: 'Must be a valid URL' },\n    ]}\n  >\n    <Input.TextArea\n      rows={2}\n      placeholder=\"e.g., https://docs.cmlite.org\"\n    />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Site Description\"\n    name=\"site_description\"\n  >\n    <Input.TextArea\n      rows={3}\n      placeholder=\"Brief description of your documentation site\"\n    />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Site Author\"\n    name=\"site_author\"\n  >\n    <Input placeholder=\"e.g., Changemaker Team\" />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Copyright\"\n    name=\"copyright\"\n  >\n    <Input placeholder=\"e.g., Copyright &copy; 2025 Changemaker\" />\n  </Form.Item>\n\n  <Divider>Repository Configuration</Divider>\n\n  <Form.Item\n    label=\"Repository URL\"\n    name=\"repo_url\"\n    rules={[{ type: 'url', message: 'Must be a valid URL' }]}\n  >\n    <Input placeholder=\"e.g., https://github.com/username/repo\" />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Repository Name\"\n    name=\"repo_name\"\n  >\n    <Input placeholder=\"e.g., changemaker-lite\" />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Edit URI\"\n    name=\"edit_uri\"\n  >\n    <Input placeholder=\"e.g., edit/main/docs/\" />\n  </Form.Item>\n\n  <Divider>Theme Configuration</Divider>\n\n  <Form.Item\n    label=\"Theme Name\"\n    name=\"theme_name\"\n    rules={[{ required: true, message: 'Theme is required' }]}\n  >\n    <Input placeholder=\"e.g., material\" />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Primary Color\"\n    name=\"theme_primary\"\n    rules={[\n      { pattern: /^#[0-9A-Fa-f]{6}$/, message: 'Must be hex color (e.g., #1976d2)' },\n    ]}\n  >\n    <Input placeholder=\"e.g., #1976d2\" />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Accent Color\"\n    name=\"theme_accent\"\n    rules={[\n      { pattern: /^#[0-9A-Fa-f]{6}$/, message: 'Must be hex color (e.g., #f50057)' },\n    ]}\n  >\n    <Input placeholder=\"e.g., #f50057\" />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Theme Features\"\n    name=\"theme_features\"\n  >\n    <Select\n      mode=\"tags\"\n      placeholder=\"Type feature name and press Enter\"\n      style={{ width: '100%' }}\n    />\n  </Form.Item>\n\n  <Divider>Plugins & Extensions</Divider>\n\n  <Form.Item\n    label=\"Plugins\"\n    name=\"plugins\"\n  >\n    <Select\n      mode=\"tags\"\n      placeholder=\"Type plugin name and press Enter\"\n      style={{ width: '100%' }}\n    />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Markdown Extensions\"\n    name=\"markdown_extensions\"\n  >\n    <Select\n      mode=\"tags\"\n      placeholder=\"Type extension name and press Enter\"\n      style={{ width: '100%' }}\n    />\n  </Form.Item>\n\n  <Divider>Additional Assets</Divider>\n\n  <Form.Item\n    label=\"Extra CSS Files\"\n    name=\"extra_css\"\n  >\n    <Select\n      mode=\"tags\"\n      placeholder=\"Type CSS file path and press Enter\"\n      style={{ width: '100%' }}\n    />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Extra JavaScript Files\"\n    name=\"extra_javascript\"\n  >\n    <Select\n      mode=\"tags\"\n      placeholder=\"Type JS file path and press Enter\"\n      style={{ width: '100%' }}\n    />\n  </Form.Item>\n\n  <Form.Item>\n    <Button\n      type=\"primary\"\n      htmlType=\"submit\"\n      loading={saving}\n      size=\"large\"\n    >\n      Save Changes\n    </Button>\n  </Form.Item>\n</Form>\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#navigation-tab-tree-rendering","title":"Navigation Tab Tree Rendering","text":"
<div>\n  <Space style={{ marginBottom: 16 }}>\n    <Button onClick={handleAddSection}>Add Section</Button>\n    <Button onClick={handleAddPage}>Add Page</Button>\n    <Button onClick={handleAddCampaignLink}>Add Campaign Link</Button>\n  </Space>\n\n  <Tree\n    treeData={convertNavToTreeData(navStructure)}\n    draggable\n    blockNode\n    onDrop={handleDrop}\n    titleRender={(node) => (\n      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>\n        <span>\n          {node.children ? (\n            <FolderOutlined style={{ marginRight: 8 }} />\n          ) : (\n            <FileOutlined style={{ marginRight: 8 }} />\n          )}\n          {node.title}\n        </span>\n        <Space>\n          <Button\n            type=\"text\"\n            size=\"small\"\n            icon={<EditOutlined />}\n            onClick={() => handleEditNavItem(node.key)}\n          />\n          <Button\n            type=\"text\"\n            size=\"small\"\n            danger\n            icon={<DeleteOutlined />}\n            onClick={() => handleDeleteNavItem(node.key)}\n          />\n        </Space>\n      </div>\n    )}\n  />\n\n  <Divider />\n\n  <Button\n    type=\"primary\"\n    onClick={handleSaveNavigation}\n    loading={saving}\n    size=\"large\"\n  >\n    Save Navigation\n  </Button>\n\n  {orphanedFiles.length > 0 && (\n    <>\n      <Divider />\n\n      <Alert\n        message=\"Orphaned Files\"\n        description={`${orphanedFiles.length} markdown files are not included in navigation`}\n        type=\"warning\"\n        showIcon\n      />\n\n      <div style={{ marginTop: 16 }}>\n        {orphanedFiles.map((file) => (\n          <div\n            key={file}\n            style={{\n              padding: '8px 12px',\n              marginBottom: 8,\n              border: '1px solid #d9d9d9',\n              borderRadius: 4,\n              display: 'flex',\n              justifyContent: 'space-between',\n              alignItems: 'center',\n            }}\n          >\n            <span>\n              <FileOutlined style={{ marginRight: 8 }} />\n              {file}\n            </span>\n            <Button\n              size=\"small\"\n              onClick={() => handleAddOrphanedFile(file)}\n            >\n              Add to Navigation\n            </Button>\n          </div>\n        ))}\n      </div>\n    </>\n  )}\n</div>\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#yaml-editor-tab-rendering","title":"YAML Editor Tab Rendering","text":"
<div>\n  {isMobile && (\n    <Alert\n      message=\"Desktop Recommended\"\n      description=\"YAML Editor is best used on desktop. Consider using Settings or Navigation tabs for mobile editing.\"\n      type=\"warning\"\n      showIcon\n      closable\n      style={{ marginBottom: 16 }}\n    />\n  )}\n\n  <Editor\n    height=\"600px\"\n    defaultLanguage=\"yaml\"\n    value={yamlContent}\n    onChange={(value) => setYamlContent(value || '')}\n    theme=\"vs-dark\"\n    options={{\n      minimap: { enabled: false },\n      fontSize: 14,\n      wordWrap: 'on',\n      automaticLayout: true,\n      scrollBeyondLastLine: false,\n      renderWhitespace: 'selection',\n      tabSize: 2,\n      insertSpaces: true,\n    }}\n    onMount={(editor, monaco) => {\n      // Register Ctrl+S keyboard shortcut\n      editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {\n        handleSaveYAML();\n      });\n    }}\n  />\n\n  <div style={{ marginTop: 16 }}>\n    <Space>\n      <Button\n        type=\"primary\"\n        onClick={handleSaveYAML}\n        loading={saving}\n        size=\"large\"\n      >\n        Save Changes\n      </Button>\n      <Typography.Text type=\"secondary\">\n        Tip: Press Ctrl+S (Cmd+S on Mac) to save\n      </Typography.Text>\n    </Space>\n  </div>\n</div>\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#build-tab-rendering","title":"Build Tab Rendering","text":"
<Card>\n  <Space direction=\"vertical\" size=\"large\" style={{ width: '100%' }}>\n    <div>\n      <Typography.Title level={4}>Build Static Site</Typography.Title>\n      <Typography.Paragraph>\n        Trigger a static site build using MkDocs. This will generate HTML files\n        from your markdown documentation.\n      </Typography.Paragraph>\n    </div>\n\n    {lastBuild && (\n      <div>\n        <Typography.Text strong>Last Build:</Typography.Text>\n        <Typography.Text style={{ marginLeft: 8 }}>\n          {dayjs(lastBuild).fromNow()}\n        </Typography.Text>\n      </div>\n    )}\n\n    <Button\n      type=\"primary\"\n      size=\"large\"\n      onClick={handleBuild}\n      loading={building}\n      icon={<RocketOutlined />}\n    >\n      {building ? 'Building...' : 'Build Site'}\n    </Button>\n\n    {building && (\n      <Alert\n        message=\"Build in Progress\"\n        description=\"Building static site... This may take 10-30 seconds.\"\n        type=\"info\"\n        showIcon\n      />\n    )}\n  </Space>\n</Card>\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#drag-and-drop-handler","title":"Drag-and-Drop Handler","text":"
const handleDrop = (info: any) => {\n  const dropKey = info.node.key;\n  const dragKey = info.dragNode.key;\n  const dropPos = info.node.pos.split('-');\n  const dropPosition = info.dropPosition - Number(dropPos[dropPos.length - 1]);\n\n  const loop = (data: NavItem[], key: string, callback: (item: NavItem, index: number, arr: NavItem[]) => void) => {\n    for (let i = 0; i < data.length; i++) {\n      if (data[i].key === key) {\n        callback(data[i], i, data);\n        return;\n      }\n      if (data[i].children) {\n        loop(data[i].children!, key, callback);\n      }\n    }\n  };\n\n  const data = [...navStructure];\n\n  // Find dragObject\n  let dragObj: NavItem;\n  loop(data, dragKey, (item, index, arr) => {\n    arr.splice(index, 1);\n    dragObj = item;\n  });\n\n  if (!info.dropToGap) {\n    // Drop on the content\n    loop(data, dropKey, (item) => {\n      item.children = item.children || [];\n      item.children.unshift(dragObj);\n    });\n  } else if (\n    (info.node.children || []).length > 0 &&\n    info.node.expanded &&\n    dropPosition === 1\n  ) {\n    // Drop to the bottom gap\n    loop(data, dropKey, (item) => {\n      item.children = item.children || [];\n      item.children.unshift(dragObj);\n    });\n  } else {\n    // Drop to the gap\n    let ar: NavItem[] = [];\n    let i: number;\n    loop(data, dropKey, (_item, index, arr) => {\n      ar = arr;\n      i = index;\n    });\n    if (dropPosition === -1) {\n      ar.splice(i!, 0, dragObj!);\n    } else {\n      ar.splice(i! + 1, 0, dragObj!);\n    }\n  }\n\n  setNavStructure(data);\n};\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#add-campaign-link-handler","title":"Add Campaign Link Handler","text":"
const handleAddCampaignLink = async () => {\n  try {\n    // Fetch active campaigns\n    const response = await api.get('/api/influence/campaigns', {\n      params: {\n        status: 'active',\n        limit: 100,\n      },\n    });\n\n    const campaigns = response.data.data.campaigns;\n\n    if (campaigns.length === 0) {\n      message.warning('No active campaigns found');\n      return;\n    }\n\n    // Show modal with campaign selector\n    let selectedCampaignId: number | null = null;\n\n    Modal.confirm({\n      title: 'Add Campaign Link',\n      content: (\n        <div style={{ marginTop: 16 }}>\n          <Typography.Text>Select a campaign to add to navigation:</Typography.Text>\n          <Select\n            style={{ width: '100%', marginTop: 8 }}\n            placeholder=\"Select campaign\"\n            onChange={(value) => {\n              selectedCampaignId = value;\n            }}\n            options={campaigns.map((campaign: any) => ({\n              label: campaign.title,\n              value: campaign.id,\n            }))}\n          />\n        </div>\n      ),\n      onOk: () => {\n        if (!selectedCampaignId) {\n          message.error('Please select a campaign');\n          return Promise.reject();\n        }\n\n        // Find selected campaign\n        const campaign = campaigns.find((c: any) => c.id === selectedCampaignId);\n\n        // Generate navigation item\n        const newNavItem: NavItem = {\n          key: `campaign-${campaign.id}`,\n          title: campaign.title,\n          file: `campaigns/${campaign.slug}.md`,\n        };\n\n        // Add to navigation structure (at end)\n        setNavStructure([...navStructure, newNavItem]);\n\n        message.success(`Added \"${campaign.title}\" to navigation`);\n      },\n    });\n  } catch (error) {\n    message.error('Failed to fetch campaigns');\n    console.error('Fetch campaigns error:', error);\n  }\n};\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#yaml-parser-with-python-tag-support","title":"YAML Parser with Python Tag Support","text":"
import yaml from 'yaml';\n\n// Parse YAML with Python tag preservation\nconst parseYAML = (yamlString: string): any => {\n  try {\n    const parsed = yaml.parse(yamlString, {\n      // Preserve Python tags\n      customTags: [\n        {\n          tag: '!relative',\n          resolve: (str: string) => ({ type: 'relative', value: str }),\n        },\n      ],\n    });\n    return parsed;\n  } catch (error) {\n    console.error('YAML parse error:', error);\n    throw new Error('Invalid YAML syntax');\n  }\n};\n\n// Stringify YAML with Python tag reconstruction\nconst stringifyYAML = (obj: any): string => {\n  try {\n    return yaml.stringify(obj, {\n      indent: 2,\n      lineWidth: 0,  // Disable line wrapping\n      // Custom replacer for Python tags\n      replacer: (key, value) => {\n        if (value && typeof value === 'object' && value.type === 'relative') {\n          return `!relative ${value.value}`;\n        }\n        return value;\n      },\n    });\n  } catch (error) {\n    console.error('YAML stringify error:', error);\n    throw new Error('Failed to stringify YAML');\n  }\n};\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#1-debounced-search-not-applicable","title":"1. Debounced Search (Not Applicable)","text":"

This page does not implement search functionality. Navigation filtering could be added with debouncing if needed.

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#2-lazy-tab-loading","title":"2. Lazy Tab Loading","text":"
// Only load tab content when tab is active\n{activeTab === 'settings' && (\n  <Form form={form} layout=\"vertical\">\n    {/* Settings form fields */}\n  </Form>\n)}\n\n{activeTab === 'navigation' && (\n  <div>\n    {/* Navigation tree */}\n  </div>\n)}\n\n{activeTab === 'yaml' && (\n  <div>\n    {/* Monaco editor */}\n  </div>\n)}\n\n{activeTab === 'build' && (\n  <Card>\n    {/* Build controls */}\n  </Card>\n)}\n

Benefit: Avoids rendering all tab contents simultaneously, especially heavy Monaco editor.

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#3-monaco-editor-lazy-loading","title":"3. Monaco Editor Lazy Loading","text":"

Monaco Editor only mounts when YAML Editor tab is active:

{activeTab === 'yaml' && (\n  <Editor\n    height=\"600px\"\n    defaultLanguage=\"yaml\"\n    value={yamlContent}\n    // ... editor config\n  />\n)}\n

Benefit: Saves ~500KB of JavaScript bundle loading and initialization time until needed.

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#4-usecallback-for-event-handlers","title":"4. useCallback for Event Handlers","text":"
const handleSaveSettings = useCallback(async () => {\n  try {\n    setSaving(true);\n    const values = form.getFieldsValue();\n    const updatedConfig = { ...config, ...values };\n    await api.put('/api/docs/config', updatedConfig);\n    message.success('Settings saved successfully');\n    await loadConfig();\n  } catch (error) {\n    message.error('Failed to save settings');\n  } finally {\n    setSaving(false);\n  }\n}, [config, form]);\n

Benefit: Prevents unnecessary re-renders of child components when handler function identity changes.

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#5-tree-data-memoization","title":"5. Tree Data Memoization","text":"
const treeData = useMemo(() => {\n  return convertNavToTreeData(navStructure);\n}, [navStructure]);\n\nreturn (\n  <Tree treeData={treeData} draggable onDrop={handleDrop} />\n);\n

Benefit: Avoids recalculating tree data structure on every render.

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#6-form-field-value-caching","title":"6. Form Field Value Caching","text":"

Ant Design Form automatically caches field values, but we explicitly set them only once after loading:

useEffect(() => {\n  if (config) {\n    form.setFieldsValue({\n      site_name: config.site_name,\n      // ... other fields\n    });\n  }\n}, [config, form]);\n

Benefit: Avoids unnecessary form re-renders and field value updates.

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#breakpoint-detection","title":"Breakpoint Detection","text":"
import { Grid } from 'antd';\n\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;  // Mobile if screen width < 768px\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#mobile-yaml-editor-warning","title":"Mobile YAML Editor Warning","text":"
{isMobile && (\n  <Alert\n    message=\"Desktop Recommended\"\n    description=\"YAML Editor is best used on desktop. Consider using Settings or Navigation tabs for mobile editing.\"\n    type=\"warning\"\n    showIcon\n    closable\n    style={{ marginBottom: 16 }}\n  />\n)}\n

Rationale: Monaco Editor provides poor UX on mobile (small screen, no keyboard shortcuts, slow rendering). Warning nudges users toward form-based Settings tab instead.

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#responsive-form-layout","title":"Responsive Form Layout","text":"
<Form layout=\"vertical\">  {/* Stacks labels above inputs on all screen sizes */}\n  <Form.Item label=\"Site Name\" name=\"site_name\">\n    <Input />\n  </Form.Item>\n</Form>\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#responsive-navigation-tree","title":"Responsive Navigation Tree","text":"

Tree component automatically adjusts to container width. Horizontal scrolling enabled for deep nesting:

<Tree\n  style={{ overflowX: 'auto' }}  // Horizontal scroll for wide trees\n  treeData={treeData}\n  draggable\n  blockNode\n/>\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#keyboard-navigation","title":"Keyboard Navigation","text":"
  1. Tab Key:
  2. Cycles through all interactive elements (tabs, form fields, buttons, tree nodes)

  3. Arrow Keys:

  4. Navigate between tabs in Tabs component
  5. Navigate tree structure (up/down/left/right)

  6. Enter Key:

  7. Submit forms
  8. Activate buttons
  9. Expand/collapse tree nodes

  10. Escape Key:

  11. Close modals
  12. Cancel drag-and-drop operations

  13. Monaco Editor Shortcuts:

  14. Ctrl+S / Cmd+S - Save YAML
  15. Ctrl+F - Find
  16. Ctrl+H - Find and replace
  17. Ctrl+Z - Undo
  18. Ctrl+Y - Redo
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#aria-labels","title":"ARIA Labels","text":"
<Button\n  aria-label=\"Save MkDocs settings\"\n  onClick={handleSaveSettings}\n>\n  Save Changes\n</Button>\n\n<Tree\n  aria-label=\"Documentation navigation tree\"\n  treeData={treeData}\n  draggable\n/>\n\n<Tabs\n  aria-label=\"MkDocs configuration tabs\"\n  activeKey={activeTab}\n  onChange={setActiveTab}\n>\n  <Tabs.TabPane tab=\"Settings\" key=\"settings\" />\n  <Tabs.TabPane tab=\"Navigation\" key=\"navigation\" />\n  <Tabs.TabPane tab=\"YAML Editor\" key=\"yaml\" />\n  <Tabs.TabPane tab=\"Build\" key=\"build\" />\n</Tabs>\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#color-contrast","title":"Color Contrast","text":"

All text meets WCAG AA standards: - Primary text: rgba(0, 0, 0, 0.85) on white background (contrast ratio 13.6:1) - Secondary text: rgba(0, 0, 0, 0.45) on white background (contrast ratio 7.5:1) - Button text: White on #1890ff (contrast ratio 4.5:1)

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#focus-indicators","title":"Focus Indicators","text":"

Ant Design provides default focus outlines for all interactive elements: - Blue outline on focused inputs - Highlight on focused tree nodes - Border on focused buttons

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#screen-reader-support","title":"Screen Reader Support","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-yaml-fails-to-save-with-invalid-yaml-syntax","title":"Problem: YAML Fails to Save with \"Invalid YAML syntax\"","text":"

Symptoms: - Clicking \"Save Changes\" in YAML Editor shows error - Error message: \"Invalid YAML syntax\"

Causes: 1. Syntax errors (missing colons, incorrect indentation, unclosed quotes) 2. Invalid characters in YAML 3. Python tags not properly formatted

Solutions:

  1. Check for syntax errors:

    # \u274c Bad: Missing colon after key\nsite_name Changemaker Lite\n\n# \u2705 Good: Proper key-value syntax\nsite_name: Changemaker Lite\n

  2. Verify indentation:

    # \u274c Bad: Inconsistent indentation (mix of spaces and tabs)\ntheme:\n  name: material\n    palette:\n      primary: blue\n\n# \u2705 Good: Consistent 2-space indentation\ntheme:\n  name: material\n  palette:\n    primary: blue\n

  3. Escape special characters:

    # \u274c Bad: Unquoted special characters\nsite_description: This is a site with: special chars\n\n# \u2705 Good: Quoted string\nsite_description: \"This is a site with: special chars\"\n

  4. Use Monaco Find to locate errors:

  5. Press Ctrl+F to open find dialog
  6. Search for : to verify all keys have values
  7. Search for \" to verify all quotes are closed

  8. Copy YAML to external validator:

  9. Copy YAML content from editor
  10. Paste into online YAML validator (e.g., yamllint.com)
  11. Fix reported errors
  12. Paste corrected YAML back into editor
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-navigation-tree-drag-and-drop-not-working","title":"Problem: Navigation Tree Drag-and-Drop Not Working","text":"

Symptoms: - Cannot drag navigation items - Items snap back to original position after drop - No visual feedback during drag

Causes: 1. Browser compatibility issues 2. JavaScript errors breaking drag handlers 3. Tree data structure corruption

Solutions:

  1. Check browser console for errors:
  2. Open DevTools (F12)
  3. Check Console tab for red errors
  4. Look for errors related to \"onDrop\" or \"Tree\"

  5. Refresh page:

  6. Hard refresh (Ctrl+Shift+R) to clear cached JavaScript
  7. Check if drag-and-drop works after refresh

  8. Try alternative reordering:

  9. Instead of drag-and-drop, use Edit buttons to change order manually
  10. Delete item and re-add in desired position

  11. Verify tree data structure:

  12. Switch to YAML Editor tab
  13. Check nav: section for corrupt structure:

    # \u274c Bad: Missing keys or nested arrays\nnav:\n  - - Home: index.md  # Double-nested array\n\n# \u2705 Good: Proper structure\nnav:\n  - Home: index.md\n  - Getting Started:\n      - Installation: getting-started/installation.md\n

  14. Report browser compatibility:

  15. Drag-and-drop may not work in older browsers
  16. Update browser to latest version
  17. Try different browser (Chrome, Firefox, Edge)
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-orphaned-files-not-appearing","title":"Problem: Orphaned Files Not Appearing","text":"

Symptoms: - \"Orphaned Files\" section is empty - Know that markdown files exist but not in navigation - Expect files to show but don't see them

Causes: 1. Files actually included in navigation (just deep in tree) 2. API not detecting files correctly 3. Files located outside docs directory

Solutions:

  1. Expand all tree nodes:
  2. Click all expand arrows in navigation tree
  3. Verify file is truly not present in any section

  4. Check file location:

  5. Orphaned file detection only scans mkdocs/docs/ directory
  6. Files in other directories won't appear
  7. Move files to mkdocs/docs/ if needed

  8. Verify file has .md extension:

  9. Only .md files detected
  10. Files with .txt, .html, etc. won't appear

  11. Refresh orphaned files:

  12. Save navigation (even without changes)
  13. API re-scans for orphaned files on save
  14. Check if files appear after save

  15. Manually add files:

  16. If orphaned detection fails, use \"Add Page\" button
  17. Enter file path manually
  18. File will be added to navigation
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-build-fails-with-error-message","title":"Problem: Build Fails with Error Message","text":"

Symptoms: - Clicking \"Build Site\" shows error - Error message displayed in alert - Build timestamp not updated

Causes: 1. Invalid YAML configuration 2. Missing markdown files referenced in navigation 3. MkDocs Docker container not running 4. Theme or plugin not installed

Solutions:

  1. Check configuration validity:
  2. Switch to Settings tab
  3. Verify all required fields filled
  4. Check for validation errors (red borders on inputs)

  5. Verify file references:

  6. Switch to YAML Editor tab
  7. Check nav: section for file paths
  8. Ensure all referenced files exist:

    nav:\n  - Home: index.md  # Must exist at mkdocs/docs/index.md\n  - Guide: guide.md  # Must exist at mkdocs/docs/guide.md\n

  9. Check MkDocs container status:

  10. Open terminal
  11. Run: docker compose ps
  12. Verify mkdocs container is \"Up\"
  13. If not, start container: docker compose up -d mkdocs

  14. View build logs:

  15. Open terminal
  16. Run: docker compose logs mkdocs
  17. Look for error messages in logs
  18. Fix issues indicated in logs (e.g., missing plugin, theme error)

  19. Verify theme and plugins installed:

  20. Themes/plugins must be installed in MkDocs container
  21. Check api/mkdocs/requirements.txt for installed packages
  22. Add missing packages to requirements.txt
  23. Rebuild container: docker compose up -d --build mkdocs
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-settings-changes-not-persisting","title":"Problem: Settings Changes Not Persisting","text":"

Symptoms: - Click \"Save Changes\" in Settings tab - Success message appears - Reload page, changes reverted to old values

Causes: 1. Database write failure 2. Form validation errors preventing save 3. YAML overriding database values

Solutions:

  1. Check browser console:
  2. Open DevTools (F12)
  3. Check Console tab for errors
  4. Look for API errors (400, 500 status codes)

  5. Verify form validation:

  6. Look for red borders on input fields
  7. Red border indicates validation error
  8. Fix validation errors before saving:

  9. Check API response:

  10. Open DevTools Network tab
  11. Click \"Save Changes\"
  12. Click PUT /api/docs/config request
  13. Check Response tab for error details

  14. Verify database connection:

  15. Open terminal
  16. Run: docker compose logs api
  17. Look for database connection errors
  18. If errors, restart API: docker compose restart api

  19. Check YAML Editor for conflicts:

  20. Switch to YAML Editor tab
  21. Save YAML (even without changes)
  22. YAML save may override database values
  23. Re-enter settings and save again
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-monaco-editor-not-loading","title":"Problem: Monaco Editor Not Loading","text":"

Symptoms: - YAML Editor tab shows blank space or loading spinner - No code editor appears - Console errors related to Monaco

Causes: 1. Slow network loading Monaco assets 2. JavaScript bundle corruption 3. Browser compatibility issues

Solutions:

  1. Wait for loading:
  2. Monaco Editor takes 2-5 seconds to load
  3. Wait for editor to fully initialize before interacting

  4. Check network requests:

  5. Open DevTools Network tab
  6. Look for failed requests to Monaco assets
  7. Failed requests indicated by red text and 4xx/5xx status
  8. If failed, refresh page to retry

  9. Clear browser cache:

  10. Hard refresh (Ctrl+Shift+R)
  11. Or clear browser cache entirely
  12. Reload page to fetch fresh assets

  13. Update browser:

  14. Monaco requires modern browser (Chrome 90+, Firefox 88+, Edge 90+)
  15. Update browser to latest version
  16. Restart browser after update

  17. Use alternative tabs:

  18. If Monaco fails, use Settings or Navigation tabs instead
  19. Both provide same functionality without Monaco Editor
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-campaign-links-not-appearing-in-dropdown","title":"Problem: Campaign Links Not Appearing in Dropdown","text":"

Symptoms: - Click \"Add Campaign Link\" - Modal appears but dropdown is empty - No campaigns to select

Causes: 1. No active campaigns in database 2. API error fetching campaigns 3. Insufficient permissions

Solutions:

  1. Verify active campaigns exist:
  2. Navigate to \"Influence\" \u2192 \"Campaigns\" in sidebar
  3. Check if any campaigns have \"Active\" status
  4. If none, create active campaign first
  5. Return to MkDocs Settings and try again

  6. Check browser console:

  7. Open DevTools (F12)
  8. Click \"Add Campaign Link\"
  9. Check Console for errors
  10. Look for API errors (401, 403, 500)

  11. Verify permissions:

  12. Campaign link insertion requires SUPER_ADMIN role
  13. Check user role in profile dropdown
  14. If not SUPER_ADMIN, request role upgrade from administrator

  15. Check API endpoint:

  16. Open DevTools Network tab
  17. Click \"Add Campaign Link\"
  18. Look for GET /api/influence/campaigns request
  19. Check Response tab for campaign data
  20. If empty response, no campaigns available

  21. Manually add campaign pages:

  22. Instead of campaign link button, use \"Add Page\" button
  23. Manually enter campaign details:
  24. Create markdown file manually in mkdocs/docs/campaigns/
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#backend-documentation","title":"Backend Documentation","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#frontend-documentation","title":"Frontend Documentation","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#feature-documentation","title":"Feature Documentation","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#api-documentation","title":"API Documentation","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#user-guides","title":"User Guides","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#deployment-documentation","title":"Deployment Documentation","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#development-documentation","title":"Development Documentation","text":""},{"location":"v2/frontend/pages/admin/n8n-page/","title":"N8nPage","text":""},{"location":"v2/frontend/pages/admin/n8n-page/#overview","title":"Overview","text":"

File: admin/src/pages/N8nPage.tsx

Route: /app/services/n8n

Role Requirements: Any authenticated user (uses authenticate middleware)

Purpose: Provides an embedded interface to the n8n workflow automation service via iframe. n8n is a node-based workflow automation tool that allows administrators to create automated workflows connecting different services (email, webhooks, databases, APIs) without writing code. This page serves as a wrapper that embeds the n8n editor with status monitoring and mobile detection.

Key Features: - Full-page iframe embed of n8n workflow editor - Service online/offline status monitoring - Mobile device detection with warning screen - \"Refresh\" and \"Open in New Tab\" buttons - Fullbleed layout for maximum editor space - Access to 200+ n8n integrations

Layout: AppLayout with fullbleed

Dependencies: - Ant Design v5 (Button, Space, Badge, Spin, Grid, Result) - Service URL builder utility

"},{"location":"v2/frontend/pages/admin/n8n-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/n8n-page/#1-service-status-monitoring","title":"1. Service Status Monitoring","text":"

Status Display: - Green \"Online\" badge when n8n is accessible - Red \"Offline\" badge when unavailable - Blue \"Checking...\" badge during status check

"},{"location":"v2/frontend/pages/admin/n8n-page/#2-mobile-device-detection","title":"2. Mobile Device Detection","text":"

Mobile Warning: - ApiOutlined icon (48px) - Message: \"The workflow editor requires a desktop browser\" - \"Open in New Tab\" button for external access

"},{"location":"v2/frontend/pages/admin/n8n-page/#3-workflow-automation","title":"3. Workflow Automation","text":"

n8n Features (within iframe): - Visual Editor: Node-based workflow canvas - 200+ Integrations: Pre-built nodes for popular services - Trigger Nodes: Start workflows on schedule, webhook, manual trigger - Action Nodes: Perform actions (send email, HTTP request, database query) - Logic Nodes: IF conditions, loops, merge data - Execution History: View workflow runs and debug errors - Credentials: Securely store API keys and passwords

Common Use Cases: - Email Notifications: Send campaign updates via SMTP - Webhook Handlers: Respond to external events (Stripe payments, GitHub webhooks) - Database Sync: Sync data between PostgreSQL and external services - Scheduled Tasks: Run cleanup jobs, generate reports, send reminders - API Integrations: Connect to Listmonk, Represent API, geocoding services

"},{"location":"v2/frontend/pages/admin/n8n-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/n8n-page/#creating-a-workflow","title":"Creating a Workflow","text":"
  1. Navigate to Workflow Automation:
  2. Click \"Services\" \u2192 \"Workflow Automation\" in sidebar
  3. Page loads with status check

  4. Wait for Load:

  5. Status badge shows \"Checking...\" then \"Online\"
  6. n8n editor loads in iframe

  7. Create New Workflow:

  8. Click \"New Workflow\" button in n8n
  9. Empty canvas appears

  10. Add Trigger Node:

  11. Click \"+\" button on canvas
  12. Select trigger type:

  13. Add Action Nodes:

  14. Click \"+\" button after trigger
  15. Search for integration (e.g., \"PostgreSQL\", \"Gmail\", \"HTTP Request\")
  16. Configure node:

  17. Test Workflow:

  18. Click \"Execute Workflow\" button
  19. View execution result for each node
  20. Check output data
  21. Debug errors if any

  22. Activate Workflow:

  23. Toggle \"Active\" switch in top-right
  24. Workflow now runs automatically based on trigger

  25. Monitor Executions:

  26. Click \"Executions\" tab
  27. View history of all workflow runs
  28. Click execution to see detailed logs
  29. Identify failed executions
"},{"location":"v2/frontend/pages/admin/n8n-page/#example-workflows","title":"Example Workflows","text":"

1. Daily Campaign Report Email: - Trigger: Schedule (daily at 9am) - Node 1: PostgreSQL query (count responses per campaign) - Node 2: Format data (create HTML email) - Node 3: Gmail send (email report to campaign manager)

2. Listmonk Subscriber Sync: - Trigger: Schedule (hourly) - Node 1: PostgreSQL query (fetch new shift signups) - Node 2: Listmonk API (create/update subscribers) - Node 3: Slack notification (notify team of sync completion)

3. Response Submission Webhook: - Trigger: Webhook (POST /webhook/response) - Node 1: Extract data from webhook payload - Node 2: PostgreSQL insert (create response record) - Node 3: Email notification (notify campaign manager)

"},{"location":"v2/frontend/pages/admin/n8n-page/#component-structure","title":"Component Structure","text":"
export default function N8nPage() {\n  const { setPageHeader } = useOutletContext<AppOutletContext>();\n  const screens = Grid.useBreakpoint();\n  const isMobile = !screens.md;\n\n  const [online, setOnline] = useState<boolean | null>(null);\n  const [config, setConfig] = useState<ServicesConfig | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  const fetchStatus = useCallback(async () => {\n    try {\n      const [statusRes, configRes] = await Promise.all([\n        api.get<ServicesStatus>('/services/status'),\n        api.get<ServicesConfig>('/services/config'),\n      ]);\n      setOnline(statusRes.data.n8n.online);\n      setConfig(configRes.data);\n    } catch {\n      setOnline(false);\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    fetchStatus();\n  }, [fetchStatus]);\n\n  const serviceUrl = config\n    ? buildServiceUrl(config.n8nSubdomain, config.domain, config.n8nPort)\n    : null;\n\n  const headerActions = useMemo(() => (\n    <Space>\n      <Badge\n        status={online === null ? 'processing' : online ? 'success' : 'error'}\n        text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}\n      />\n      <Button icon={<ReloadOutlined />} onClick={fetchStatus} size=\"small\">\n        Refresh\n      </Button>\n      {serviceUrl && (\n        <Button icon={<LinkOutlined />} href={serviceUrl} target=\"_blank\" size=\"small\">\n          Open in New Tab\n        </Button>\n      )}\n    </Space>\n  ), [online, fetchStatus, serviceUrl]);\n\n  useEffect(() => {\n    setPageHeader({ title: 'Workflow Automation', actions: headerActions, fullBleed: true });\n    return () => setPageHeader(null);\n  }, [setPageHeader, headerActions]);\n\n  if (isMobile) {\n    return (\n      <Result\n        status=\"info\"\n        title=\"Desktop Required\"\n        subTitle=\"The workflow editor requires a desktop browser with a larger screen.\"\n        icon={<ApiOutlined style={{ fontSize: 48 }} />}\n      />\n    );\n  }\n\n  if (loading) {\n    return (\n      <div style={{ textAlign: 'center', padding: 80 }}>\n        <Spin size=\"large\" />\n      </div>\n    );\n  }\n\n  if (!online || !serviceUrl) {\n    return (\n      <Result\n        status=\"error\"\n        title=\"n8n Unavailable\"\n        subTitle=\"n8n is not running or could not be reached. Check that the n8n container is started.\"\n        extra={\n          <Button type=\"primary\" onClick={fetchStatus}>\n            Retry\n          </Button>\n        }\n      />\n    );\n  }\n\n  return (\n    <iframe\n      src={serviceUrl}\n      style={{\n        width: '100%',\n        height: 'calc(100vh - 64px)',\n        border: 'none',\n        display: 'block',\n      }}\n      title=\"n8n Workflows\"\n    />\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/n8n-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/n8n-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/services/status - Check n8n health
  2. GET /api/services/config - Fetch subdomain/port config
"},{"location":"v2/frontend/pages/admin/n8n-page/#example-responses","title":"Example Responses","text":"

Status:

{\n  \"n8n\": { \"online\": true },\n  \"mailhog\": { \"online\": true },\n  \"nocodb\": { \"online\": true }\n}\n

Config:

{\n  \"domain\": \"cmlite.org\",\n  \"n8nSubdomain\": \"n8n\",\n  \"n8nPort\": 5678\n}\n

Service URL: - Production: http://n8n.cmlite.org - Development: http://localhost:5678

"},{"location":"v2/frontend/pages/admin/n8n-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/n8n-page/#problem-n8n-login-required","title":"Problem: n8n Login Required","text":"

Symptoms: - Iframe shows n8n login screen - Cannot access workflows without credentials

Solutions:

  1. Check n8n credentials:
  2. Username: N8N_BASIC_AUTH_USER env var
  3. Password: N8N_BASIC_AUTH_PASSWORD env var

  4. Login manually:

  5. Enter credentials in n8n login form
  6. n8n saves session in browser cookies

  7. Disable authentication (dev only):

  8. Set N8N_BASIC_AUTH_ACTIVE=false in .env
  9. Restart n8n: docker compose restart n8n
"},{"location":"v2/frontend/pages/admin/n8n-page/#problem-workflow-execution-failed","title":"Problem: Workflow Execution Failed","text":"

Symptoms: - Workflow shows red error icon - Execution stopped at specific node - Error message displayed

Solutions:

  1. Check node configuration:
  2. Click failed node
  3. Review parameters
  4. Verify credentials valid

  5. Check credentials:

  6. Click \"Credentials\" in n8n sidebar
  7. Test credential connection
  8. Re-enter if expired

  9. View error details:

  10. Click execution in history
  11. Expand failed node
  12. Read error message
  13. Common errors:

  14. Test individual nodes:

  15. Right-click node \u2192 \"Execute Node\"
  16. Test each node in isolation
  17. Identify problematic node
"},{"location":"v2/frontend/pages/admin/n8n-page/#problem-webhook-not-triggering","title":"Problem: Webhook Not Triggering","text":"

Symptoms: - Webhook workflow not executing - External service sending webhooks but n8n not responding

Solutions:

  1. Check webhook URL:
  2. Copy webhook URL from n8n trigger node
  3. Example: http://n8n.cmlite.org/webhook/response
  4. Verify URL accessible from external service

  5. Check workflow active:

  6. Toggle \"Active\" switch must be ON
  7. Inactive workflows don't respond to webhooks

  8. Test webhook manually:

    curl -X POST http://n8n.cmlite.org/webhook/response \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"test\": \"data\"}'\n

  9. Should return 200 OK
  10. Check execution history in n8n

  11. Check nginx routing:

  12. Webhook URL must route through nginx
  13. Verify proxy_pass configured for n8n
"},{"location":"v2/frontend/pages/admin/n8n-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/nocodb-page/","title":"NocoDBPage","text":""},{"location":"v2/frontend/pages/admin/nocodb-page/#overview","title":"Overview","text":"

File: admin/src/pages/NocoDBPage.tsx

Route: /app/services/nocodb

Role Requirements: Any authenticated user (uses authenticate middleware)

Purpose: Provides an embedded interface to the NocoDB database browser via iframe. NocoDB is a no-code database platform that provides a spreadsheet-like interface for viewing and editing database tables. This page serves as a read-only data browser for administrators to explore the PostgreSQL database without SQL knowledge.

Key Features: - Full-page iframe embed of NocoDB service - Service online/offline status monitoring - Mobile device detection with warning screen - \"Refresh\" and \"Open in New Tab\" buttons - Fullbleed layout (no padding) - Read-only access to database tables

Layout: AppLayout with fullbleed

Dependencies: - Ant Design v5 (Button, Space, Badge, Spin, Grid, Result) - Service URL builder utility

"},{"location":"v2/frontend/pages/admin/nocodb-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/nocodb-page/#1-service-status-monitoring","title":"1. Service Status Monitoring","text":"

Status Display: - Green \"Online\" badge when NocoDB is accessible - Red \"Offline\" badge when unavailable - Blue \"Checking...\" badge during status check

"},{"location":"v2/frontend/pages/admin/nocodb-page/#2-mobile-device-detection","title":"2. Mobile Device Detection","text":"

Mobile Warning: - DatabaseOutlined icon (48px) - Message: \"The database browser requires a desktop browser\" - \"Open in New Tab\" button for external access

"},{"location":"v2/frontend/pages/admin/nocodb-page/#3-database-browsing","title":"3. Database Browsing","text":"

NocoDB Features (within iframe): - Table View: Spreadsheet-like interface for all tables - Filter: Filter rows by column values - Sort: Sort rows by any column - Search: Global table search - Export: Export tables to CSV/Excel - Read-Only: No edit/delete capabilities (view only)

Tables Available: - User, RefreshToken (auth) - Campaign, Representative, Response, CampaignEmail (influence) - Location, Cut, Shift, ShiftSignup (map) - CanvassSession, CanvassVisit (canvassing) - LandingPage, PageBlock (pages) - And more...

"},{"location":"v2/frontend/pages/admin/nocodb-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/nocodb-page/#browsing-database-tables","title":"Browsing Database Tables","text":"
  1. Navigate to Database Browser:
  2. Click \"Services\" \u2192 \"Database Browser\" in sidebar
  3. Page loads with status check

  4. Wait for Load:

  5. Status badge shows \"Checking...\" then \"Online\"
  6. NocoDB interface loads in iframe

  7. Select Table:

  8. Left sidebar lists all tables
  9. Click table name to view contents

  10. View Table Data:

  11. Spreadsheet view with all rows and columns
  12. Scroll horizontally/vertically
  13. Click row to expand details

  14. Filter Data:

  15. Click filter icon in column header
  16. Select filter condition (equals, contains, etc.)
  17. Enter filter value
  18. View filtered results

  19. Export Data:

  20. Click \"...\" menu in table header
  21. Select \"Export\" \u2192 \"CSV\" or \"Excel\"
  22. Download file for offline analysis

  23. Common Use Cases:

  24. User Management: Browse User table to see all accounts
  25. Campaign Analysis: View Campaign responses by filtering Response table
  26. Location Data: Export Location table for mapping analysis
  27. Audit Trail: Check RefreshToken table for login activity
"},{"location":"v2/frontend/pages/admin/nocodb-page/#component-structure","title":"Component Structure","text":"
export default function NocoDBPage() {\n  const { setPageHeader } = useOutletContext<AppOutletContext>();\n  const screens = Grid.useBreakpoint();\n  const isMobile = !screens.md;\n\n  const [online, setOnline] = useState<boolean | null>(null);\n  const [config, setConfig] = useState<ServicesConfig | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  const fetchStatus = useCallback(async () => {\n    try {\n      const [statusRes, configRes] = await Promise.all([\n        api.get<ServicesStatus>('/services/status'),\n        api.get<ServicesConfig>('/services/config'),\n      ]);\n      setOnline(statusRes.data.nocodb.online);\n      setConfig(configRes.data);\n    } catch {\n      setOnline(false);\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    fetchStatus();\n  }, [fetchStatus]);\n\n  const serviceUrl = config\n    ? buildServiceUrl(config.nocodbSubdomain, config.domain, config.nocodbPort)\n    : null;\n\n  const headerActions = useMemo(() => (\n    <Space>\n      <Badge\n        status={online === null ? 'processing' : online ? 'success' : 'error'}\n        text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}\n      />\n      <Button icon={<ReloadOutlined />} onClick={fetchStatus} size=\"small\">\n        Refresh\n      </Button>\n      {serviceUrl && (\n        <Button icon={<LinkOutlined />} href={serviceUrl} target=\"_blank\" size=\"small\">\n          Open in New Tab\n        </Button>\n      )}\n    </Space>\n  ), [online, fetchStatus, serviceUrl]);\n\n  useEffect(() => {\n    setPageHeader({ title: 'Database Browser', actions: headerActions, fullBleed: true });\n    return () => setPageHeader(null);\n  }, [setPageHeader, headerActions]);\n\n  if (isMobile) {\n    return (\n      <Result\n        status=\"info\"\n        title=\"Desktop Required\"\n        subTitle=\"The database browser requires a desktop browser with a larger screen.\"\n        icon={<DatabaseOutlined style={{ fontSize: 48 }} />}\n      />\n    );\n  }\n\n  if (loading) {\n    return (\n      <div style={{ textAlign: 'center', padding: 80 }}>\n        <Spin size=\"large\" />\n      </div>\n    );\n  }\n\n  if (!online || !serviceUrl) {\n    return (\n      <Result\n        status=\"error\"\n        title=\"NocoDB Unavailable\"\n        subTitle=\"NocoDB is not running or could not be reached. Check that the NocoDB container is started.\"\n        extra={\n          <Button type=\"primary\" onClick={fetchStatus}>\n            Retry\n          </Button>\n        }\n      />\n    );\n  }\n\n  return (\n    <iframe\n      src={serviceUrl}\n      style={{\n        width: '100%',\n        height: 'calc(100vh - 64px)',\n        border: 'none',\n        display: 'block',\n      }}\n      title=\"NocoDB\"\n    />\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/nocodb-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/nocodb-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/services/status - Check NocoDB health
  2. GET /api/services/config - Fetch subdomain/port config
"},{"location":"v2/frontend/pages/admin/nocodb-page/#example-responses","title":"Example Responses","text":"

Status:

{\n  \"nocodb\": { \"online\": true },\n  \"mailhog\": { \"online\": true },\n  \"n8n\": { \"online\": true }\n}\n

Config:

{\n  \"domain\": \"cmlite.org\",\n  \"nocodbSubdomain\": \"db\",\n  \"nocodbPort\": 8091\n}\n

Service URL: - Production: http://db.cmlite.org - Development: http://localhost:8091

"},{"location":"v2/frontend/pages/admin/nocodb-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/nocodb-page/#problem-nocodb-login-required","title":"Problem: NocoDB Login Required","text":"

Symptoms: - Iframe shows NocoDB login screen - Cannot access tables without credentials

Solutions:

  1. Check NocoDB admin credentials:
  2. Username: NC_ADMIN_EMAIL env var
  3. Password: NC_ADMIN_PASSWORD env var

  4. Login manually:

  5. Enter admin credentials in NocoDB login form
  6. NocoDB saves session in browser cookies

  7. Reset password:

    docker compose exec nocodb sh\nnc-cli reset-password --email admin@example.com\n

"},{"location":"v2/frontend/pages/admin/nocodb-page/#problem-tables-not-visible","title":"Problem: Tables Not Visible","text":"

Symptoms: - NocoDB loads but no tables in left sidebar - \"No projects found\" message

Solutions:

  1. Check NocoDB configuration:
  2. NocoDB must be connected to PostgreSQL
  3. Check NC_DB env var: postgresql://user:password@host:port/database

  4. Create NocoDB project:

  5. Click \"New Project\" button
  6. Connect to PostgreSQL database
  7. Enter database credentials (from V2_POSTGRES_* env vars)

  8. Restart NocoDB:

    docker compose restart nocodb\n

"},{"location":"v2/frontend/pages/admin/nocodb-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/observability-page/","title":"ObservabilityPage","text":""},{"location":"v2/frontend/pages/admin/observability-page/#overview","title":"Overview","text":"

File: admin/src/pages/ObservabilityPage.tsx Route: /app/observability Role Requirements: SUPER_ADMIN

ObservabilityPage is the system monitoring and alerting dashboard for Changemaker Lite's observability stack. It provides a unified interface for viewing Prometheus metrics, Grafana dashboards, and Alertmanager alerts. The page features three tabs (Overview, Monitoring, Alerts), service status monitoring for 7 monitoring services, key metrics grid, active alerts table, and embedded iframes for Grafana and Alertmanager with lazy loading.

The page integrates with: - Prometheus (port 9090) - Metrics collection and time-series database - Grafana (port 3001) - Metrics visualization and dashboards - Alertmanager (port 9093) - Alert management and routing - cAdvisor (port 8080) - Container metrics - Node Exporter (port 9100) - Host system metrics - Redis Exporter (port 9121) - Redis metrics - Gotify (port 8889) - Notification service

Key Features: - Three-tab interface (Overview/Monitoring/Alerts) with radio button switcher - Service status cards (7 services) with online/offline indicators - Metrics grid showing key application metrics (API uptime, queue size, sessions, etc.) - Active alerts table with severity indicators - Lazy-loaded Grafana iframe (Application Overview dashboard) - Lazy-loaded Alertmanager iframe - Auto-start banner for offline services - \"Open Grafana\" button for full-screen access

Key Components: - ServiceStatusCard for each monitoring service - MetricsGrid for application metrics - AlertsTable for active alerts - IframeErrorBoundary for iframe error handling - Radio.Group for tab switching

"},{"location":"v2/frontend/pages/admin/observability-page/#screenshot","title":"Screenshot","text":"

[Screenshot: ObservabilityPage showing three-tab interface at top (Overview/Monitoring/Alerts radio buttons), Overview tab displaying service status cards in grid (Prometheus, Grafana, Alertmanager, cAdvisor, Node Exporter, Redis Exporter, Gotify) with green/red online/offline indicators, key metrics grid below showing API stats, and active alerts table at bottom. Header has Refresh and \"Open Grafana\" buttons.]

"},{"location":"v2/frontend/pages/admin/observability-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/observability-page/#core-features","title":"Core Features","text":"
  1. Three-Tab Interface
  2. Overview Tab: Service status + metrics + alerts summary
  3. Monitoring Tab: Embedded Grafana Application Overview dashboard
  4. Alerts Tab: Embedded Alertmanager UI
  5. Radio button switcher in page header
  6. Tab state preserved during session

  7. Service Status Monitoring

  8. 7 service status cards:
  9. Online/offline badge indicators
  10. Clickable URL to open service in new tab
  11. Responsive grid layout (4 columns on desktop, 2 on tablet, 1 on mobile)

  12. Auto-Start Banner

  13. Warning alert at top of Overview tab when all services offline
  14. Shows Docker Compose command to start monitoring services
  15. Command: docker compose --profile monitoring up -d
  16. Only shows when servicesOnline === 0

  17. Key Metrics Grid

  18. Displays application-specific metrics from Prometheus
  19. Examples: API uptime, email queue size, active canvass sessions, total locations
  20. Only visible when at least one service online
  21. Powered by MetricsGrid component

  22. Active Alerts Table

  23. Shows currently firing alerts from Alertmanager
  24. Columns: Alert name, severity, status, start time
  25. Color-coded severity (critical=red, warning=orange, info=blue)
  26. Only visible when at least one service online
  27. Powered by AlertsTable component

  28. Grafana Dashboard Iframe

  29. Embedded Application Overview dashboard
  30. Lazy-loaded (only loads when Monitoring tab selected)
  31. Full-height iframe (calc(100vh - 200px))
  32. Sandboxed for security (allow-scripts, allow-same-origin, allow-forms)
  33. Error boundary for graceful failure handling
  34. Shows warning if Grafana offline

  35. Alertmanager Iframe

  36. Embedded Alertmanager UI
  37. Lazy-loaded (only loads when Alerts tab selected)
  38. Full-height iframe (calc(100vh - 200px))
  39. Sandboxed for security
  40. Error boundary for graceful failure handling
  41. Shows warning if Alertmanager offline

  42. Refresh Button

  43. Refreshes all data (status, metrics, alerts) in parallel
  44. Visible in all tabs
  45. Loading state during refresh

  46. Open Grafana Button

  47. Primary button in header (blue)
  48. Opens Grafana in new tab at full URL
  49. Only visible when Grafana online
  50. Provides full-screen Grafana access
"},{"location":"v2/frontend/pages/admin/observability-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/observability-page/#viewing-system-status-overview-tab","title":"Viewing System Status (Overview Tab)","text":"
  1. Navigate to page: Admin sidebar \u2192 System \u2192 Observability
  2. Overview tab loads: Shows service status cards, metrics grid, alerts table
  3. Check service status: Green badges = online, red badges = offline
  4. Review metrics: Scan key application metrics (uptime, queue size, etc.)
  5. Check alerts: Review active alerts table for firing alerts
"},{"location":"v2/frontend/pages/admin/observability-page/#starting-monitoring-services","title":"Starting Monitoring Services","text":"

If all services offline: 1. See warning banner: Yellow alert at top with Docker Compose command 2. Copy command: docker compose --profile monitoring up -d 3. Run in terminal: Execute command in project directory 4. Wait ~30 seconds: Services take time to start 5. Click Refresh: Reload page to verify services online 6. Banner disappears: Warning banner no longer shown

"},{"location":"v2/frontend/pages/admin/observability-page/#viewing-grafana-dashboards","title":"Viewing Grafana Dashboards","text":"
  1. Click \"Monitoring\" tab: Radio button in header
  2. Grafana iframe loads: Embedded Application Overview dashboard
  3. Interact with dashboard: Pan, zoom, change time range, etc.
  4. Full-screen access: Click \"Open Grafana\" button for new tab
  5. Explore more dashboards: In Grafana UI, browse other dashboards (Host Metrics, Docker Containers, etc.)
"},{"location":"v2/frontend/pages/admin/observability-page/#managing-alerts","title":"Managing Alerts","text":"
  1. Click \"Alerts\" tab: Radio button in header
  2. Alertmanager iframe loads: Embedded alert management UI
  3. View alert groups: See all firing alerts grouped by label
  4. Silence alerts: Click Silence button to temporarily suppress
  5. Configure routes: Modify alert routing rules (if SUPER_ADMIN)
"},{"location":"v2/frontend/pages/admin/observability-page/#refreshing-data","title":"Refreshing Data","text":"
  1. Click Refresh button: In header (any tab)
  2. All data reloads: Service status, metrics, alerts fetched in parallel
  3. Loading state: Brief spinner or loading indicator
  4. Data updates: New status/metrics/alerts displayed
"},{"location":"v2/frontend/pages/admin/observability-page/#opening-service-directly","title":"Opening Service Directly","text":"
  1. Click on service status card URL (if service online)
  2. New tab opens: Direct access to service (e.g., Prometheus, Grafana, Alertmanager)
  3. Full service UI: No iframe restrictions, full functionality
"},{"location":"v2/frontend/pages/admin/observability-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/observability-page/#tab-switcher-header","title":"Tab Switcher (Header)","text":"
<Radio.Group\n  value={activeTab}\n  onChange={e => setActiveTab(e.target.value)}\n  buttonStyle=\"solid\"\n>\n  <Radio.Button value=\"overview\">\n    <DashboardOutlined /> Overview\n  </Radio.Button>\n  <Radio.Button value=\"monitoring\">\n    <LineChartOutlined /> Monitoring\n  </Radio.Button>\n  <Radio.Button value=\"alerts\">\n    <AlertOutlined /> Alerts\n  </Radio.Button>\n</Radio.Group>\n

Solid button style: Active tab highlighted with blue background.

"},{"location":"v2/frontend/pages/admin/observability-page/#service-status-card","title":"Service Status Card","text":"
<ServiceStatusCard\n  name=\"Prometheus\"\n  online={status?.prometheus?.online || false}\n  url={status?.prometheus?.url || ''}\n  icon={<DashboardOutlined />}\n/>\n

ServiceStatusCard Component:

interface ServiceStatusCardProps {\n  name: string;\n  online: boolean;\n  url: string;\n  icon: React.ReactNode;\n}\n\n// Displays:\n// - Service name (bold)\n// - Badge (green \"Online\" or red \"Offline\")\n// - Icon\n// - Clickable link to service URL (if online)\n

"},{"location":"v2/frontend/pages/admin/observability-page/#auto-start-banner","title":"Auto-Start Banner","text":"
{allOffline && (\n  <Alert\n    message=\"Monitoring services are offline\"\n    description={\n      <>\n        Start monitoring services with: <code>docker compose --profile monitoring up -d</code>\n      </>\n    }\n    type=\"warning\"\n    showIcon\n    style={{ marginBottom: 16 }}\n  />\n)}\n

Condition: allOffline = servicesOnline === 0

"},{"location":"v2/frontend/pages/admin/observability-page/#service-status-grid","title":"Service Status Grid","text":"
<Card title=\"Service Status\" style={{ marginBottom: 16 }}>\n  <Row gutter={[16, 16]}>\n    <Col xs={24} sm={12} lg={6}>\n      <ServiceStatusCard name=\"Prometheus\" online={...} url={...} icon={<DashboardOutlined />} />\n    </Col>\n    <Col xs={24} sm={12} lg={6}>\n      <ServiceStatusCard name=\"Grafana\" online={...} url={...} icon={<LineChartOutlined />} />\n    </Col>\n    {/* 5 more cards... */}\n  </Row>\n</Card>\n

Responsive Grid: - Desktop (lg, \u2265 992px): 4 columns (6/24 = 25% width each) - Tablet (sm, \u2265 576px): 2 columns (12/24 = 50% width each) - Mobile (xs, < 576px): 1 column (24/24 = 100% width)

"},{"location":"v2/frontend/pages/admin/observability-page/#metrics-grid","title":"Metrics Grid","text":"
{!allOffline && <MetricsGrid metrics={metrics} loading={loading} />}\n

MetricsGrid Component: - Displays application metrics from Prometheus - Examples: API uptime, email queue size, active sessions, location count - Styled as grid of Statistic cards - Only renders when at least one service online

"},{"location":"v2/frontend/pages/admin/observability-page/#alerts-table","title":"Alerts Table","text":"
{!allOffline && alerts && (\n  <AlertsTable alerts={alerts.alerts || []} loading={loading} />\n)}\n

AlertsTable Component: - Ant Design Table with columns: - Alert name - Severity (color-coded tag) - Status (firing/resolved) - Start time (relative) - Pagination if > 10 alerts - Only renders when at least one service online

"},{"location":"v2/frontend/pages/admin/observability-page/#grafana-iframe-monitoring-tab","title":"Grafana Iframe (Monitoring Tab)","text":"
<IframeErrorBoundary serviceName=\"Grafana\">\n  <Card styles={{ body: { padding: 0 } }}>\n    {grafanaIframeSrc ? (\n      <iframe\n        src={grafanaIframeSrc}\n        style={{\n          width: '100%',\n          height: 'calc(100vh - 200px)',\n          border: 'none',\n        }}\n        title=\"Grafana Dashboard\"\n        aria-label=\"Embedded Grafana application overview dashboard\"\n        sandbox=\"allow-scripts allow-same-origin allow-forms\"\n        referrerPolicy=\"strict-origin-when-cross-origin\"\n        loading=\"lazy\"\n      />\n    ) : (\n      <Spin />\n    )}\n  </Card>\n</IframeErrorBoundary>\n

Lazy Loading Logic:

useEffect(() => {\n  if (activeTab === 'monitoring' && !grafanaInitialized.current && status?.grafana.online) {\n    try {\n      const url = buildMonitoringUrl('grafana', 3005, '/d/application-overview');\n      setGrafanaIframeSrc(url);\n      grafanaInitialized.current = true;\n    } catch (error) {\n      console.error('Failed to construct Grafana URL:', error);\n    }\n  }\n}, [activeTab, status]);\n

Pattern: Iframe src set only when: 1. Monitoring tab selected 2. Not already initialized (ref tracks this) 3. Grafana is online

"},{"location":"v2/frontend/pages/admin/observability-page/#alertmanager-iframe-alerts-tab","title":"Alertmanager Iframe (Alerts Tab)","text":"
<IframeErrorBoundary serviceName=\"Alertmanager\">\n  <Card styles={{ body: { padding: 0 } }}>\n    {alertmanagerIframeSrc ? (\n      <iframe\n        src={alertmanagerIframeSrc}\n        style={{\n          width: '100%',\n          height: 'calc(100vh - 200px)',\n          border: 'none',\n        }}\n        title=\"Alertmanager\"\n        aria-label=\"Embedded Alertmanager alert management interface\"\n        sandbox=\"allow-scripts allow-same-origin allow-forms\"\n        referrerPolicy=\"strict-origin-when-cross-origin\"\n        loading=\"lazy\"\n      />\n    ) : (\n      <Spin />\n    )}\n  </Card>\n</IframeErrorBoundary>\n

Same lazy loading pattern as Grafana.

"},{"location":"v2/frontend/pages/admin/observability-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/observability-page/#local-state","title":"Local State","text":"

Data State:

const [status, setStatus] = useState<ObservabilityStatus | null>(null);\nconst [metrics, setMetrics] = useState<MetricsSummary | null>(null);\nconst [alerts, setAlerts] = useState<AlertsResponse | null>(null);\nconst [loading, setLoading] = useState(true);\n

UI State:

const [activeTab, setActiveTab] = useState<TabKey>('overview');\nconst [grafanaIframeSrc, setGrafanaIframeSrc] = useState<string | null>(null);\nconst [alertmanagerIframeSrc, setAlertmanagerIframeSrc] = useState<string | null>(null);\nconst grafanaInitialized = useRef(false);\nconst alertmanagerInitialized = useRef(false);\n

"},{"location":"v2/frontend/pages/admin/observability-page/#data-fetching","title":"Data Fetching","text":"

Fetch Status:

const fetchStatus = useCallback(async () => {\n  try {\n    const res = await api.get<ObservabilityStatus>('/observability/status');\n    setStatus(res.data);\n  } catch {\n    // Status fetch failed \u2014 leave null\n  }\n}, []);\n

Fetch Metrics:

const fetchMetrics = useCallback(async () => {\n  try {\n    const res = await api.get<MetricsSummary>('/observability/metrics-summary');\n    setMetrics(res.data);\n  } catch {\n    // Metrics fetch may fail if Prometheus is offline\n  }\n}, []);\n

Fetch Alerts:

const fetchAlerts = useCallback(async () => {\n  try {\n    const res = await api.get<AlertsResponse>('/observability/alerts');\n    setAlerts(res.data);\n  } catch {\n    // Alerts fetch may fail if Alertmanager is offline\n  }\n}, []);\n

Fetch All (Parallel):

const fetchAll = useCallback(async () => {\n  setLoading(true);\n  await Promise.all([fetchStatus(), fetchMetrics(), fetchAlerts()]);\n  setLoading(false);\n}, [fetchStatus, fetchMetrics, fetchAlerts]);\n

Benefit: Parallel API calls load faster than sequential.

"},{"location":"v2/frontend/pages/admin/observability-page/#lazy-iframe-loading","title":"Lazy Iframe Loading","text":"
useEffect(() => {\n  if (activeTab === 'monitoring' && !grafanaInitialized.current && status?.grafana.online) {\n    try {\n      const url = buildMonitoringUrl('grafana', 3005, '/d/application-overview');\n      setGrafanaIframeSrc(url);\n      grafanaInitialized.current = true;\n    } catch (error) {\n      console.error('Failed to construct Grafana URL:', error);\n    }\n  }\n}, [activeTab, status]);\n

Why Lazy Loading? - Avoids loading heavy iframes until needed - Improves initial page load performance - Saves bandwidth if user never clicks Monitoring/Alerts tabs

Why useRef? - Tracks initialization state without triggering re-renders - Prevents redundant iframe loads on subsequent tab switches

"},{"location":"v2/frontend/pages/admin/observability-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/observability-page/#endpoints-used","title":"Endpoints Used","text":"

GET /observability/status - Fetch service online/offline status

const { data } = await api.get<ObservabilityStatus>('/observability/status');\n

Response:

{\n  \"prometheus\": {\n    \"online\": true,\n    \"url\": \"http://localhost:9090\"\n  },\n  \"grafana\": {\n    \"online\": true,\n    \"url\": \"http://localhost:3001\"\n  },\n  \"alertmanager\": {\n    \"online\": true,\n    \"url\": \"http://localhost:9093\"\n  },\n  \"cadvisor\": {\n    \"online\": true,\n    \"url\": \"http://localhost:8080\"\n  },\n  \"nodeExporter\": {\n    \"online\": true,\n    \"url\": \"http://localhost:9100\"\n  },\n  \"redisExporter\": {\n    \"online\": true,\n    \"url\": \"http://localhost:9121\"\n  },\n  \"gotify\": {\n    \"online\": false,\n    \"url\": \"http://localhost:8889\"\n  }\n}\n

GET /observability/metrics-summary - Fetch key application metrics

const { data } = await api.get<MetricsSummary>('/observability/metrics-summary');\n

Response:

{\n  \"apiUptime\": 99.8,\n  \"emailQueueSize\": 42,\n  \"activeCanvassSessions\": 5,\n  \"totalLocations\": 12543,\n  \"httpRequestsTotal\": 156789,\n  \"httpRequestDurationSeconds\": 0.234\n}\n

GET /observability/alerts - Fetch active alerts

const { data } = await api.get<AlertsResponse>('/observability/alerts');\n

Response:

{\n  \"alerts\": [\n    {\n      \"id\": \"alert_1\",\n      \"name\": \"HighMemoryUsage\",\n      \"severity\": \"warning\",\n      \"status\": \"firing\",\n      \"startTime\": \"2026-02-11T10:30:00Z\",\n      \"labels\": {\n        \"alertname\": \"HighMemoryUsage\",\n        \"instance\": \"api:4000\",\n        \"severity\": \"warning\"\n      },\n      \"annotations\": {\n        \"summary\": \"Memory usage above 80%\",\n        \"description\": \"API container using 85% memory\"\n      }\n    }\n  ]\n}\n

"},{"location":"v2/frontend/pages/admin/observability-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/observability-page/#parallel-api-calls","title":"Parallel API Calls","text":"
const fetchAll = useCallback(async () => {\n  setLoading(true);\n  await Promise.all([fetchStatus(), fetchMetrics(), fetchAlerts()]);\n  setLoading(false);\n}, [fetchStatus, fetchMetrics, fetchAlerts]);\n

Benefit: Loads all data simultaneously (faster than sequential).

"},{"location":"v2/frontend/pages/admin/observability-page/#lazy-iframe-loading-pattern","title":"Lazy Iframe Loading Pattern","text":"
const grafanaInitialized = useRef(false);\n\nuseEffect(() => {\n  if (activeTab === 'monitoring' && !grafanaInitialized.current && status?.grafana.online) {\n    const url = buildMonitoringUrl('grafana', 3005, '/d/application-overview');\n    setGrafanaIframeSrc(url);\n    grafanaInitialized.current = true;\n  }\n}, [activeTab, status]);\n

Pattern: 1. Check if tab active 2. Check if not already initialized (useRef) 3. Check if service online 4. Build URL and set iframe src 5. Mark as initialized (prevents redundant loads)

"},{"location":"v2/frontend/pages/admin/observability-page/#services-online-count","title":"Services Online Count","text":"
const servicesOnline = status\n  ? Object.values(status).filter((s: ServiceStatus) => s.online).length\n  : 0;\nconst allOffline = servicesOnline === 0;\n

Counts online services from status object values.

"},{"location":"v2/frontend/pages/admin/observability-page/#conditional-rendering-based-on-service-status","title":"Conditional Rendering Based on Service Status","text":"
{allOffline && (\n  <Alert\n    message=\"Monitoring services are offline\"\n    description={<>Start with: <code>docker compose --profile monitoring up -d</code></>}\n    type=\"warning\"\n  />\n)}\n\n{!allOffline && <MetricsGrid metrics={metrics} loading={loading} />}\n{!allOffline && alerts && <AlertsTable alerts={alerts.alerts || []} loading={loading} />}\n

Pattern: Show banner if all offline, hide metrics/alerts if all offline.

"},{"location":"v2/frontend/pages/admin/observability-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/observability-page/#parallel-api-calls_1","title":"Parallel API Calls","text":"

Three API calls made simultaneously instead of sequentially:

await Promise.all([fetchStatus(), fetchMetrics(), fetchAlerts()]);\n

Benefit: Reduces total load time from ~300ms (100ms \u00d7 3) to ~100ms (max of 3 parallel requests).

"},{"location":"v2/frontend/pages/admin/observability-page/#lazy-iframe-loading_1","title":"Lazy Iframe Loading","text":"

Iframes only load when tab selected: - Grafana iframe: activeTab === 'monitoring' - Alertmanager iframe: activeTab === 'alerts'

Benefit: Saves bandwidth and reduces initial page load time. Heavy iframes (~1-2MB each) not loaded unless needed.

"},{"location":"v2/frontend/pages/admin/observability-page/#useref-for-initialization-tracking","title":"useRef for Initialization Tracking","text":"
const grafanaInitialized = useRef(false);\n

Why useRef instead of useState? - Doesn't trigger re-renders when updated - Persists across re-renders - Perfect for tracking initialization state

"},{"location":"v2/frontend/pages/admin/observability-page/#conditional-component-rendering","title":"Conditional Component Rendering","text":"
{!allOffline && <MetricsGrid metrics={metrics} loading={loading} />}\n

Avoids rendering heavy components when no services online (no data to show).

"},{"location":"v2/frontend/pages/admin/observability-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/observability-page/#service-status-grid_1","title":"Service Status Grid","text":"
<Row gutter={[16, 16]}>\n  <Col xs={24} sm={12} lg={6}>\n    <ServiceStatusCard ... />\n  </Col>\n  {/* 6 more cards... */}\n</Row>\n

Responsive Breakpoints: - Desktop (lg, \u2265 992px): 4 columns (6/24 each) - Tablet (sm, \u2265 576px): 2 columns (12/24 each) - Mobile (xs, < 576px): 1 column (24/24 each)

"},{"location":"v2/frontend/pages/admin/observability-page/#iframe-height","title":"Iframe Height","text":"
<iframe style={{ height: 'calc(100vh - 200px)' }} />\n

Dynamic height: Fills viewport minus header/footer (responsive to window resize).

"},{"location":"v2/frontend/pages/admin/observability-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/observability-page/#iframe-labels","title":"Iframe Labels","text":"
<iframe\n  title=\"Grafana Dashboard\"\n  aria-label=\"Embedded Grafana application overview dashboard\"\n/>\n

Screen reader support: Clear description of iframe content.

"},{"location":"v2/frontend/pages/admin/observability-page/#button-labels","title":"Button Labels","text":"
<Button icon={<ReloadOutlined />}>Refresh</Button>\n<Button icon={<LinkOutlined />}>Open Grafana</Button>\n

Not icon-only buttons \u2013 text labels for clarity.

"},{"location":"v2/frontend/pages/admin/observability-page/#service-status-badges","title":"Service Status Badges","text":"
<Badge status=\"success\" text=\"Online\" />\n<Badge status=\"error\" text=\"Offline\" />\n

Color + text: Not relying on color alone for status indication.

"},{"location":"v2/frontend/pages/admin/observability-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/observability-page/#all-services-offline","title":"All Services Offline","text":"

Symptoms: - Warning banner at top - All service status cards show red \"Offline\" - No metrics or alerts displayed

Cause: Monitoring services not started (Docker Compose profile monitoring not active)

Solution:

# Start monitoring services\ndocker compose --profile monitoring up -d\n\n# Verify services running\ndocker compose ps | grep -E \"(prometheus|grafana|alertmanager)\"\n\n# Check logs if services fail to start\ndocker compose logs prometheus grafana alertmanager\n

"},{"location":"v2/frontend/pages/admin/observability-page/#grafanaalertmanager-iframe-not-loading","title":"Grafana/Alertmanager Iframe Not Loading","text":"

Symptoms: - Blank iframe or loading spinner forever - Console errors about iframe src

Causes: 1. Service offline (check Overview tab status) 2. CORS policy blocking iframe 3. Network error

Debug:

# Check Grafana container\ndocker compose logs grafana\n\n# Test Grafana directly\ncurl http://localhost:3001\n\n# Check nginx proxy (if using)\ndocker compose logs nginx | grep grafana\n

"},{"location":"v2/frontend/pages/admin/observability-page/#metrics-not-showing","title":"Metrics Not Showing","text":"

Symptoms: - MetricsGrid empty or shows zeros - \"Failed to load metrics\" error

Cause: Prometheus offline or not scraping metrics

Solutions:

# Check Prometheus status\ncurl http://localhost:9090/-/healthy\n\n# Check Prometheus targets (should show API as \"up\")\ncurl http://localhost:9090/api/v1/targets\n\n# Verify API is exposing /metrics endpoint\ncurl http://localhost:4000/metrics\n

"},{"location":"v2/frontend/pages/admin/observability-page/#alerts-not-showing","title":"Alerts Not Showing","text":"

Symptoms: - AlertsTable empty - No alerts firing (but should be)

Causes: 1. Alertmanager offline 2. No alerts configured in Prometheus 3. Alerts resolved (not firing)

Debug:

# Check Alertmanager status\ncurl http://localhost:9093/-/healthy\n\n# Check Prometheus alerts\ncurl http://localhost:9090/api/v1/alerts\n\n# Check alert rules config\ndocker compose exec api cat /app/configs/prometheus/alerts.yml\n

"},{"location":"v2/frontend/pages/admin/observability-page/#open-grafana-button-not-visible","title":"\"Open Grafana\" Button Not Visible","text":"

Cause: Grafana offline

Expected Behavior:

{status?.grafana.online && (\n  <Button href={status.grafana.url} target=\"_blank\">\n    Open Grafana\n  </Button>\n)}\n

Button only shows when Grafana online.

"},{"location":"v2/frontend/pages/admin/observability-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/observability-page/#backend-integration","title":"Backend Integration","text":""},{"location":"v2/frontend/pages/admin/observability-page/#features_1","title":"Features","text":""},{"location":"v2/frontend/pages/admin/observability-page/#deployment","title":"Deployment","text":""},{"location":"v2/frontend/pages/admin/observability-page/#troubleshooting_1","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/observability-page/#user-guides","title":"User Guides","text":""},{"location":"v2/frontend/pages/admin/observability-page/#external-resources","title":"External Resources","text":""},{"location":"v2/frontend/pages/admin/observability-page/#frontend-components","title":"Frontend Components","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/","title":"PageEditorPage","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#overview","title":"Overview","text":"

File: admin/src/pages/PageEditorPage.tsx

Route: /app/pages/:id/edit

Role Requirements: Any authenticated user (uses authenticate middleware)

Purpose: Provides a full-screen dual-mode editor for landing pages with visual WYSIWYG editing (GrapesJS) and raw HTML code editing (Monaco Editor). This page is the primary interface for creating and editing landing pages, supporting both drag-and-drop visual design for non-technical users and direct HTML/CSS editing for developers. The editor operates in full-screen mode without the AppLayout wrapper to maximize editing space.

Key Features: - Dual editor modes (Visual/Code) toggled per page - Full-screen editing interface (no AppLayout) - GrapesJS visual editor with custom blocks - Monaco Editor for raw HTML editing - Real-time save with Ctrl+S keyboard shortcut - Live preview for published pages - Publish/unpublish toggle - Mobile device detection with warning screen - Auto-save on Ctrl+S in code mode - Editor state managed via useRef for performance

Layout: Full-screen (no AppLayout wrapper)

Dependencies: - Ant Design v5 (Button, Switch, Space, Typography, Tag, Spin, Grid, Result) - Monaco Editor (@monaco-editor/react) - GrapesJS (via GrapesJSEditor component wrapper) - react-router-dom (useParams, useNavigate)

"},{"location":"v2/frontend/pages/admin/page-editor-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#1-dual-editor-modes","title":"1. Dual Editor Modes","text":"

Visual Mode (GrapesJS): - Drag-and-drop interface - Custom block library (loaded from API) - Component tree navigation - Style manager (CSS properties) - Trait manager (component attributes) - Asset manager (images, files) - Canvas preview (desktop/tablet/mobile) - Undo/redo - Full-screen toggle - Export HTML/CSS

Code Mode (Monaco Editor): - Syntax highlighting for HTML - Line numbers - Word wrap enabled - Auto-formatting - Dark theme - Minimap disabled for cleaner view - Automatic layout adjustment - Ctrl+S keyboard shortcut for save - Direct HTML editing (no CSS/JS extraction)

Mode Selection: - Set when creating page in LandingPagesPage - editorMode field: \"VISUAL\" or \"CODE\" - Cannot switch modes within editor (navigate back to pages list to change) - Mode displayed as colored tag in toolbar

"},{"location":"v2/frontend/pages/admin/page-editor-page/#2-toolbar-controls","title":"2. Toolbar Controls","text":"

Left Section: - Back Button - Navigate to pages list - Page Title - Current page name - Slug Display - Public URL preview (/p/:slug) - Mode Tag - Visual (green) or Code (blue)

Right Section: - Published Toggle - Switch to enable/disable public access - Live Tag - Visible when published - Preview Button - Opens public page in new tab (only when published) - Save Button - Manual save trigger (primary action)

"},{"location":"v2/frontend/pages/admin/page-editor-page/#3-auto-save-keyboard-shortcuts","title":"3. Auto-Save & Keyboard Shortcuts","text":"

Code Mode Shortcuts: - Ctrl+S / Cmd+S - Save page (prevents browser default) - Keyboard event handler registered on mount - Handler cleaned up on unmount

Visual Mode Save: - Save button triggers editorRef.current?.triggerSave() - GrapesJS editor handles internal save via forwardRef

"},{"location":"v2/frontend/pages/admin/page-editor-page/#4-mobile-device-detection","title":"4. Mobile Device Detection","text":"

Mobile Warning: - Detects screen width < 768px (md breakpoint) - Shows Result component with \"Desktop Required\" message - \"Back to Pages\" button for navigation - Prevents editor loading on mobile devices - Different message for visual vs code mode

"},{"location":"v2/frontend/pages/admin/page-editor-page/#5-loading-error-states","title":"5. Loading & Error States","text":"

Loading State: - Full-screen centered spinner - Displayed while fetching page data + blocks - Minimum height: 100vh

Error Handling: - Failed fetch shows error message - Auto-navigates back to pages list - Prevents editor render on missing page

"},{"location":"v2/frontend/pages/admin/page-editor-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#opening-a-page-for-editing","title":"Opening a Page for Editing","text":"
  1. Navigate to Pages List:
  2. Go to /app/pages (LandingPagesPage)
  3. View table of all landing pages

  4. Select Page to Edit:

  5. Click \"Edit\" button in page row
  6. Opens editor in full-screen mode
  7. URL changes to /app/pages/:id/edit

  8. Wait for Editor Load:

  9. Loading spinner appears
  10. Page data fetched from API
  11. Visual mode: block library also loaded
  12. Editor renders based on mode
"},{"location":"v2/frontend/pages/admin/page-editor-page/#editing-in-visual-mode","title":"Editing in Visual Mode","text":"
  1. Use GrapesJS Interface:
  2. Add Components: Drag blocks from left sidebar onto canvas
  3. Move Components: Click and drag to reposition
  4. Edit Text: Double-click text to edit inline
  5. Style Components: Select component, use Style Manager in right panel
  6. Change Attributes: Use Trait Manager for component properties
  7. Upload Images: Use Asset Manager to add media

  8. Canvas Controls:

  9. Toggle device preview (desktop/tablet/mobile)
  10. Toggle fullscreen mode
  11. Toggle borders/padding visualization

  12. Save Changes:

  13. Click \"Save\" button in toolbar (or Ctrl+S)
  14. Editor extracts:
  15. All three sent to API via PUT request
  16. Success message: \"Page saved\"
"},{"location":"v2/frontend/pages/admin/page-editor-page/#editing-in-code-mode","title":"Editing in Code Mode","text":"
  1. Edit HTML Directly:
  2. Monaco editor displays current HTML output
  3. Edit HTML structure, inline styles, content
  4. Syntax highlighting for HTML tags

  5. Save Changes:

  6. Press Ctrl+S (or Cmd+S on Mac)
  7. Or click \"Save\" button in toolbar
  8. Raw HTML content sent to API
  9. Success message: \"Page saved\"

  10. Limitations:

  11. No visual preview within editor
  12. Must publish and use Preview button to see changes
  13. Changes don't update GrapesJS project data (one-way sync)
"},{"location":"v2/frontend/pages/admin/page-editor-page/#publishing-a-page","title":"Publishing a Page","text":"
  1. Toggle Published Switch:
  2. Switch in top-right toolbar
  3. Green = published, Gray = unpublished
  4. API updates published field immediately

  5. When Published:

  6. \"Live\" tag appears next to switch
  7. \"Preview\" button becomes visible
  8. Page accessible at /p/:slug URL

  9. Preview Published Page:

  10. Click \"Preview\" button (eye icon)
  11. Opens new browser tab to /p/:slug
  12. Shows rendered page as public users see it
"},{"location":"v2/frontend/pages/admin/page-editor-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#main-component-structure","title":"Main Component Structure","text":"
export default function PageEditorPage() {\n  const { id } = useParams<{ id: string }>();\n  const navigate = useNavigate();\n  const screens = Grid.useBreakpoint();\n  const isMobile = !screens.md;\n  const { token } = theme.useToken();\n\n  // State\n  const [page, setPage] = useState<LandingPage | null>(null);\n  const [blocks, setBlocks] = useState<PageBlock[]>([]);\n  const [loading, setLoading] = useState(true);\n  const [saving, setSaving] = useState(false);\n  const [codeContent, setCodeContent] = useState('');\n  const editorRef = useRef<GrapesJSEditorHandle>(null);\n\n  // Derived state\n  const isCodeMode = page?.editorMode === 'CODE';\n\n  // Fetch page + blocks (Visual mode only)\n  useEffect(() => {\n    const fetchData = async () => {\n      try {\n        if (isCodeMode) {\n          const pageRes = await api.get<LandingPage>(`/pages/${id}`);\n          setPage(pageRes.data);\n          setCodeContent(pageRes.data.htmlOutput || '');\n        } else {\n          const [pageRes, blocksRes] = await Promise.all([\n            api.get<LandingPage>(`/pages/${id}`),\n            api.get<PageBlock[]>('/page-blocks'),\n          ]);\n          setPage(pageRes.data);\n          setBlocks(blocksRes.data);\n          setCodeContent(pageRes.data.htmlOutput || '');\n        }\n      } catch {\n        message.error('Failed to load page');\n        navigate('/app/pages');\n      } finally {\n        setLoading(false);\n      }\n    };\n    fetchData();\n  }, [id]);\n\n  // Ctrl+S keyboard shortcut (code mode only)\n  useEffect(() => {\n    if (!isCodeMode) return;\n    const handler = (e: KeyboardEvent) => {\n      if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n        e.preventDefault();\n        handleSaveCode();\n      }\n    };\n    window.addEventListener('keydown', handler);\n    return () => window.removeEventListener('keydown', handler);\n  }, [isCodeMode, handleSaveCode]);\n\n  // Conditional render based on state\n  if (loading) return <Spin />;\n  if (!page) return null;\n  if (isMobile) return <MobileWarning />;\n\n  return (\n    <div style={{ height: '100vh' }}>\n      <Toolbar />\n      {isCodeMode ? <MonacoEditor /> : <GrapesJSEditor />}\n    </div>\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/page-editor-page/#toolbar-component","title":"Toolbar Component","text":"

Structure: - Full-width sticky header - Dark background (colorBgBase) - Border bottom separator - Two-column layout (Space components)

Left Section:

<Space>\n  <Button type=\"text\" icon={<ArrowLeftOutlined />} onClick={goBack} />\n  <Text strong>{page.title}</Text>\n  <Text>/p/{page.slug}</Text>\n  <Tag color={isCodeMode ? 'blue' : 'green'}>\n    {isCodeMode ? 'Code' : 'Visual'}\n  </Tag>\n</Space>\n

Right Section:

<Space>\n  <Space size={4}>\n    <Text>Published</Text>\n    <Switch checked={page.published} onChange={handleTogglePublished} />\n  </Space>\n  {page.published && <Tag color=\"green\">Live</Tag>}\n  {page.published && (\n    <Button icon={<EyeOutlined />} onClick={() => window.open(`/p/${page.slug}`)}>\n      Preview\n    </Button>\n  )}\n  <Button type=\"primary\" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>\n    Save\n  </Button>\n</Space>\n

"},{"location":"v2/frontend/pages/admin/page-editor-page/#grapesjs-editor-integration","title":"GrapesJS Editor Integration","text":"

Component:

<GrapesJSEditor\n  ref={editorRef}\n  initialData={page.blocks as Record<string, unknown>}\n  onSave={handleSaveVisual}\n  customBlocks={blocks}\n/>\n

Save Callback:

const handleSaveVisual = useCallback(async (data: {\n  projectData: Record<string, unknown>;\n  html: string;\n  css: string;\n}) => {\n  setSaving(true);\n  try {\n    const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {\n      blocks: data.projectData,\n      htmlOutput: data.html,\n      cssOutput: data.css,\n    });\n    setPage(updated);\n    message.success('Page saved');\n  } catch {\n    message.error('Failed to save page');\n  } finally {\n    setSaving(false);\n  }\n}, [page]);\n

Trigger Save (from parent):

editorRef.current?.triggerSave();\n

"},{"location":"v2/frontend/pages/admin/page-editor-page/#monaco-editor-integration","title":"Monaco Editor Integration","text":"

Component:

<Editor\n  height=\"100%\"\n  defaultLanguage=\"html\"\n  theme=\"vs-dark\"\n  value={codeContent}\n  onChange={(value) => setCodeContent(value ?? '')}\n  options={{\n    wordWrap: 'on',\n    minimap: { enabled: false },\n    fontSize: 14,\n    scrollBeyondLastLine: false,\n    automaticLayout: true,\n  }}\n/>\n

Save Handler:

const handleSaveCode = useCallback(async () => {\n  setSaving(true);\n  try {\n    const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {\n      htmlOutput: codeContent,\n    });\n    setPage(updated);\n    message.success('Page saved');\n  } catch {\n    message.error('Failed to save page');\n  } finally {\n    setSaving(false);\n  }\n}, [page, codeContent]);\n

"},{"location":"v2/frontend/pages/admin/page-editor-page/#mobile-warning-component","title":"Mobile Warning Component","text":"

Conditional Render:

if (isMobile) {\n  return (\n    <div style={{\n      display: 'flex',\n      flexDirection: 'column',\n      alignItems: 'center',\n      justifyContent: 'center',\n      height: '100vh',\n      padding: 24,\n      background: token.colorBgBase,\n    }}>\n      <Result\n        status=\"info\"\n        title=\"Desktop Required\"\n        subTitle={`The ${isCodeMode ? 'code' : 'page'} editor requires a desktop browser. Please switch to a larger screen to edit this page.`}\n        extra={\n          <Button type=\"primary\" onClick={() => navigate('/app/pages')}>\n            Back to Pages\n          </Button>\n        }\n      />\n    </div>\n  );\n}\n

Breakpoint Detection: - Uses Grid.useBreakpoint() hook - isMobile = !screens.md (screen width < 768px) - Early return prevents editor initialization

"},{"location":"v2/frontend/pages/admin/page-editor-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#local-component-state-usestate","title":"Local Component State (useState)","text":"
// Page data\nconst [page, setPage] = useState<LandingPage | null>(null);\n\n// Block library (Visual mode only)\nconst [blocks, setBlocks] = useState<PageBlock[]>([]);\n\n// Loading state\nconst [loading, setLoading] = useState(true);\n\n// Save in progress state\nconst [saving, setSaving] = useState(false);\n\n// Monaco editor content (Code mode)\nconst [codeContent, setCodeContent] = useState('');\n
"},{"location":"v2/frontend/pages/admin/page-editor-page/#refs-useref","title":"Refs (useRef)","text":"
// GrapesJS editor handle\nconst editorRef = useRef<GrapesJSEditorHandle>(null);\n

Why useRef? - GrapesJS editor controlled externally - Parent triggers save via editorRef.current?.triggerSave() - No re-renders when editor state changes - Performance optimization for large canvas

"},{"location":"v2/frontend/pages/admin/page-editor-page/#derived-state","title":"Derived State","text":"
// Computed from page data\nconst isCodeMode = page?.editorMode === 'CODE';\n\n// Responsive breakpoint\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n\n// Theme tokens\nconst { token } = theme.useToken();\n
"},{"location":"v2/frontend/pages/admin/page-editor-page/#state-flow","title":"State Flow","text":"
  1. Component Mounts:
  2. loading set to true
  3. useEffect triggers fetch based on mode
  4. Visual mode: parallel fetch page + blocks
  5. Code mode: fetch page only
  6. Sets page, blocks, codeContent
  7. Sets loading to false

  8. User Edits Content:

  9. Visual mode: GrapesJS manages internal state
  10. Code mode: Monaco onChange updates codeContent

  11. User Saves:

  12. Visual mode: editorRef.current?.triggerSave() \u2192 handleSaveVisual callback
  13. Code mode: handleSaveCode directly
  14. Sets saving to true
  15. API PUT request
  16. Updates page with response
  17. Sets saving to false

  18. User Toggles Published:

  19. API PUT request with published field
  20. Updates page with response
  21. UI updates (Live tag, Preview button)
"},{"location":"v2/frontend/pages/admin/page-editor-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/pages/:id - Fetch page data
  2. GET /api/page-blocks - Fetch custom block library (Visual mode only)
  3. PUT /api/pages/:id - Update page (save, publish)
"},{"location":"v2/frontend/pages/admin/page-editor-page/#api-calls","title":"API Calls","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#1-fetch-page-blocks-visual-mode","title":"1. Fetch Page + Blocks (Visual Mode)","text":"
const [pageRes, blocksRes] = await Promise.all([\n  api.get<LandingPage>(`/pages/${id}`),\n  api.get<PageBlock[]>('/page-blocks'),\n]);\nsetPage(pageRes.data);\nsetBlocks(blocksRes.data);\nsetCodeContent(pageRes.data.htmlOutput || '');\n

LandingPage Response:

{\n  \"id\": \"123e4567-e89b-12d3-a456-426614174000\",\n  \"title\": \"Campaign Launch\",\n  \"slug\": \"campaign-launch\",\n  \"editorMode\": \"VISUAL\",\n  \"blocks\": {\n    \"pages\": [...],\n    \"styles\": [...],\n    \"components\": [...]\n  },\n  \"htmlOutput\": \"<html>...</html>\",\n  \"cssOutput\": \".container { ... }\",\n  \"published\": false,\n  \"createdAt\": \"2025-02-10T12:00:00Z\",\n  \"updatedAt\": \"2025-02-10T14:30:00Z\"\n}\n

PageBlock Response:

[\n  {\n    \"id\": \"block-hero\",\n    \"label\": \"Hero Section\",\n    \"category\": \"sections\",\n    \"content\": \"<div class='hero'>...</div>\",\n    \"media\": \"<svg>...</svg>\",\n    \"attributes\": { \"class\": \"gjs-block\" }\n  },\n  ...\n]\n

"},{"location":"v2/frontend/pages/admin/page-editor-page/#2-fetch-page-only-code-mode","title":"2. Fetch Page Only (Code Mode)","text":"
const pageRes = await api.get<LandingPage>(`/pages/${id}`);\nsetPage(pageRes.data);\nsetCodeContent(pageRes.data.htmlOutput || '');\n
"},{"location":"v2/frontend/pages/admin/page-editor-page/#3-save-visual-mode-changes","title":"3. Save Visual Mode Changes","text":"
const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {\n  blocks: data.projectData,\n  htmlOutput: data.html,\n  cssOutput: data.css,\n});\nsetPage(updated);\n

Request Body:

{\n  \"blocks\": {\n    \"pages\": [...],\n    \"styles\": [...],\n    \"components\": [...]\n  },\n  \"htmlOutput\": \"<html>...</html>\",\n  \"cssOutput\": \".container { ... }\"\n}\n

"},{"location":"v2/frontend/pages/admin/page-editor-page/#4-save-code-mode-changes","title":"4. Save Code Mode Changes","text":"
const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {\n  htmlOutput: codeContent,\n});\nsetPage(updated);\n

Request Body:

{\n  \"htmlOutput\": \"<!DOCTYPE html>\\n<html>...</html>\"\n}\n

"},{"location":"v2/frontend/pages/admin/page-editor-page/#5-toggle-published","title":"5. Toggle Published","text":"
const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {\n  published: !page.published,\n});\nsetPage(updated);\nmessage.success(updated.published ? 'Page published' : 'Page unpublished');\n

Request Body:

{\n  \"published\": true\n}\n

"},{"location":"v2/frontend/pages/admin/page-editor-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#complete-save-visual-mode-flow","title":"Complete Save Visual Mode Flow","text":"
const handleSaveVisual = useCallback(async (data: {\n  projectData: Record<string, unknown>;\n  html: string;\n  css: string;\n}) => {\n  if (!page) return;\n  setSaving(true);\n  try {\n    const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {\n      blocks: data.projectData,\n      htmlOutput: data.html,\n      cssOutput: data.css,\n    });\n    setPage(updated);\n    message.success('Page saved');\n  } catch {\n    message.error('Failed to save page');\n  } finally {\n    setSaving(false);\n  }\n}, [page]);\n
"},{"location":"v2/frontend/pages/admin/page-editor-page/#keyboard-shortcut-handler","title":"Keyboard Shortcut Handler","text":"
useEffect(() => {\n  if (!isCodeMode) return;  // Only in code mode\n\n  const handler = (e: KeyboardEvent) => {\n    if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n      e.preventDefault();  // Prevent browser save dialog\n      handleSaveCode();\n    }\n  };\n\n  window.addEventListener('keydown', handler);\n  return () => window.removeEventListener('keydown', handler);  // Cleanup\n}, [isCodeMode, handleSaveCode]);\n
"},{"location":"v2/frontend/pages/admin/page-editor-page/#conditional-api-fetch-pattern","title":"Conditional API Fetch Pattern","text":"
useEffect(() => {\n  const fetchData = async () => {\n    try {\n      if (isCodeMode) {\n        // Code mode: page only\n        const pageRes = await api.get<LandingPage>(`/pages/${id}`);\n        setPage(pageRes.data);\n        setCodeContent(pageRes.data.htmlOutput || '');\n      } else {\n        // Visual mode: page + blocks in parallel\n        const [pageRes, blocksRes] = await Promise.all([\n          api.get<LandingPage>(`/pages/${id}`),\n          api.get<PageBlock[]>('/page-blocks'),\n        ]);\n        setPage(pageRes.data);\n        setBlocks(blocksRes.data);\n        setCodeContent(pageRes.data.htmlOutput || '');\n      }\n    } catch {\n      message.error('Failed to load page');\n      navigate('/app/pages');\n    } finally {\n      setLoading(false);\n    }\n  };\n  fetchData();\n}, [id]); // Only re-fetch if page ID changes\n
"},{"location":"v2/frontend/pages/admin/page-editor-page/#editor-ref-save-trigger","title":"Editor Ref Save Trigger","text":"
// Parent component\n<Button\n  type=\"primary\"\n  icon={<SaveOutlined />}\n  loading={saving}\n  onClick={() => {\n    if (isCodeMode) {\n      handleSaveCode();\n    } else {\n      editorRef.current?.triggerSave();  // Trigger GrapesJS save\n    }\n  }}\n>\n  Save\n</Button>\n\n// GrapesJSEditor component\n<GrapesJSEditor\n  ref={editorRef}\n  initialData={page.blocks}\n  onSave={handleSaveVisual}  // Callback receives extracted data\n  customBlocks={blocks}\n/>\n
"},{"location":"v2/frontend/pages/admin/page-editor-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#1-parallel-api-requests-visual-mode","title":"1. Parallel API Requests (Visual Mode)","text":"
const [pageRes, blocksRes] = await Promise.all([\n  api.get<LandingPage>(`/pages/${id}`),\n  api.get<PageBlock[]>('/page-blocks'),\n]);\n

Benefit: Reduces loading time by ~50% (2 sequential requests \u2192 1 parallel batch).

"},{"location":"v2/frontend/pages/admin/page-editor-page/#2-conditional-block-loading","title":"2. Conditional Block Loading","text":"
if (isCodeMode) {\n  // Skip blocks fetch in code mode\n  const pageRes = await api.get<LandingPage>(`/pages/${id}`);\n} else {\n  // Load blocks only for visual mode\n  const [pageRes, blocksRes] = await Promise.all([...]);\n}\n

Benefit: Saves unnecessary API call in code mode (blocks not used).

"},{"location":"v2/frontend/pages/admin/page-editor-page/#3-useref-for-editor-handle","title":"3. useRef for Editor Handle","text":"
const editorRef = useRef<GrapesJSEditorHandle>(null);\n

Why useRef? - GrapesJS editor has large internal state (component tree, styles, assets) - useRef prevents re-renders when editor state changes - Parent only needs to trigger save, not track editor state - Performance critical for large page designs

"},{"location":"v2/frontend/pages/admin/page-editor-page/#4-usecallback-for-save-handlers","title":"4. useCallback for Save Handlers","text":"
const handleSaveVisual = useCallback(async (data) => {\n  // ...\n}, [page]);  // Only recreate when page changes\n\nconst handleSaveCode = useCallback(async () => {\n  // ...\n}, [page, codeContent]);  // Only recreate when deps change\n

Benefit: Prevents unnecessary function recreation on every render.

"},{"location":"v2/frontend/pages/admin/page-editor-page/#5-early-mobile-detection","title":"5. Early Mobile Detection","text":"
if (isMobile) {\n  return <MobileWarning />;  // No editor initialization\n}\n

Benefit: Skips heavy editor initialization on mobile devices (saves memory + CPU).

"},{"location":"v2/frontend/pages/admin/page-editor-page/#6-automatic-monaco-layout","title":"6. Automatic Monaco Layout","text":"
<Editor\n  options={{\n    automaticLayout: true,  // Auto-adjust on window resize\n    minimap: { enabled: false },  // Disable minimap to save CPU\n    scrollBeyondLastLine: false,  // Reduce DOM size\n  }}\n/>\n

Benefit: Reduces Monaco memory footprint by disabling minimap (can use 100MB+ on large files).

"},{"location":"v2/frontend/pages/admin/page-editor-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#mobile-detection","title":"Mobile Detection","text":"

Breakpoint: - Uses Grid.useBreakpoint() hook - Mobile if !screens.md (screen width < 768px)

Mobile Warning Screen:

if (isMobile) {\n  return (\n    <div style={{\n      display: 'flex',\n      flexDirection: 'column',\n      alignItems: 'center',\n      justifyContent: 'center',\n      height: '100vh',\n      padding: 24,\n      background: token.colorBgBase,\n    }}>\n      <Result\n        status=\"info\"\n        title=\"Desktop Required\"\n        subTitle={`The ${isCodeMode ? 'code' : 'page'} editor requires a desktop browser...`}\n        extra={\n          <Button type=\"primary\" onClick={() => navigate('/app/pages')}>\n            Back to Pages\n          </Button>\n        }\n      />\n    </div>\n  );\n}\n

Why Mobile Warning? - GrapesJS requires large screen for drag-and-drop UI (canvas + panels + toolbar) - Monaco editor impractical on mobile keyboards - Touch gestures conflict with editor interactions - Better UX to redirect users to desktop device

"},{"location":"v2/frontend/pages/admin/page-editor-page/#full-screen-layout","title":"Full-Screen Layout","text":"

No AppLayout Wrapper: - Page routed outside AppLayout component - Uses full viewport height (100vh) - No sidebar navigation - Maximizes editing canvas space

Layout Structure:

<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>\n  <Toolbar />  {/* Fixed header */}\n  <Editor />   {/* Flex-grow to fill remaining space */}\n</div>\n

"},{"location":"v2/frontend/pages/admin/page-editor-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#keyboard-navigation","title":"Keyboard Navigation","text":"
  1. Tab Key:
  2. Cycles through toolbar buttons (Back, Save, Preview, Toggle)
  3. Enters editor focus (Monaco or GrapesJS canvas)

  4. Ctrl+S / Cmd+S:

  5. Save shortcut (code mode only)
  6. Prevents browser default \"Save Page As\" dialog

  7. GrapesJS Keyboard Shortcuts:

  8. Ctrl+Z: Undo
  9. Ctrl+Shift+Z: Redo
  10. Delete: Remove selected component
  11. Ctrl+C/V: Copy/paste components

  12. Monaco Editor Shortcuts:

  13. Ctrl+S: Save (custom handler)
  14. Ctrl+F: Find
  15. Ctrl+H: Find and replace
  16. Ctrl+/: Toggle comment
  17. Alt+Up/Down: Move line up/down
"},{"location":"v2/frontend/pages/admin/page-editor-page/#aria-labels","title":"ARIA Labels","text":"
<Button\n  type=\"text\"\n  icon={<ArrowLeftOutlined />}\n  onClick={() => navigate('/app/pages')}\n  aria-label=\"Back to pages list\"\n/>\n

Screen Reader Announcements: - Button labels announced via aria-label - Switch state announced (\"Published\" / \"Unpublished\") - Tag colors announced by screen readers

"},{"location":"v2/frontend/pages/admin/page-editor-page/#focus-management","title":"Focus Management","text":"

Toolbar Focus Order: 1. Back button 2. Published switch 3. Preview button (if visible) 4. Save button

Editor Focus: - Monaco: automatic focus management via Monaco API - GrapesJS: focus enters canvas on click

"},{"location":"v2/frontend/pages/admin/page-editor-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#problem-editor-not-loading","title":"Problem: Editor Not Loading","text":"

Symptoms: - Blank screen after loading spinner disappears - Console errors related to GrapesJS or Monaco

Solutions:

  1. Check page data in API response:
    curl -H \"Authorization: Bearer <token>\" \\\n  http://localhost:4000/api/pages/<page-id>\n
  2. Verify blocks, htmlOutput, editorMode fields exist

  3. Check browser console:

  4. Open DevTools Console (F12)
  5. Look for JavaScript errors
  6. Common errors:

  7. Clear browser cache:

  8. Monaco and GrapesJS cache resources
  9. Ctrl+Shift+R (hard refresh)

  10. Check network tab:

  11. Verify API requests complete successfully
  12. Verify block library loads (Visual mode)
"},{"location":"v2/frontend/pages/admin/page-editor-page/#problem-save-button-not-working","title":"Problem: Save Button Not Working","text":"

Symptoms: - Click Save button, no success message - Loading spinner appears but never completes - Console shows 400/500 errors

Solutions:

  1. Check API request in Network tab:
  2. Look for PUT /api/pages/:id request
  3. Check request payload (should have htmlOutput, blocks, cssOutput)
  4. Check response status code

  5. Visual Mode - Invalid blocks data:

  6. GrapesJS may generate invalid JSON
  7. Check console for serialization errors
  8. Try creating new page instead of editing corrupt one

  9. Code Mode - Invalid HTML:

  10. API may validate HTML structure
  11. Check for missing closing tags
  12. Check for script injection attempts (blocked by CSP)

  13. Network timeout:

  14. Large pages (>1MB HTML) may timeout
  15. Increase Axios timeout in admin/src/lib/api.ts
  16. Optimize HTML output (minify, remove unused CSS)
"},{"location":"v2/frontend/pages/admin/page-editor-page/#problem-ctrls-not-saving-code-mode","title":"Problem: Ctrl+S Not Saving (Code Mode)","text":"

Symptoms: - Press Ctrl+S, nothing happens - Browser \"Save Page As\" dialog appears instead

Solutions:

  1. Check browser focus:
  2. Ensure Monaco editor is focused (click inside editor)
  3. Keyboard handler requires window focus

  4. Check browser extensions:

  5. Extensions may intercept Ctrl+S
  6. Test in incognito mode
  7. Disable extensions one by one

  8. Mac users: Use Cmd+S instead of Ctrl+S

  9. Handler supports both e.ctrlKey and e.metaKey

  10. Manual save as fallback:

  11. Click \"Save\" button in toolbar
  12. Same effect as Ctrl+S
"},{"location":"v2/frontend/pages/admin/page-editor-page/#problem-published-page-not-accessible","title":"Problem: Published Page Not Accessible","text":"

Symptoms: - Toggle \"Published\" switch to ON - Navigate to /p/:slug, get 404 error

Solutions:

  1. Check slug uniqueness:
  2. Slug must be unique across all pages
  3. Check for URL conflicts with existing routes

  4. Check page published status:

    curl -H \"Authorization: Bearer <token>\" \\\n  http://localhost:4000/api/pages/<page-id>\n

  5. Verify \"published\": true in response

  6. Check public route registration:

  7. Open admin/src/App.tsx
  8. Verify public route exists:

    <Route path=\"/p/:slug\" element={<LandingPage />} />\n

  9. Check nginx routing:

  10. Public pages served through nginx
  11. Verify nginx reverse proxy configuration

  12. Hard refresh public page:

  13. Ctrl+Shift+R to bypass cache
  14. Browser may cache 404 response
"},{"location":"v2/frontend/pages/admin/page-editor-page/#problem-grapesjs-not-loading-custom-blocks","title":"Problem: GrapesJS Not Loading Custom Blocks","text":"

Symptoms: - Visual editor loads but block panel is empty - Only default blocks visible (Text, Image, etc.)

Solutions:

  1. Check blocks API response:
    curl -H \"Authorization: Bearer <token>\" \\\n  http://localhost:4000/api/page-blocks\n
  2. Should return array of blocks with label, content, media

  3. Check blocks passed to GrapesJS:

  4. Add console.log in PageEditorPage:
    console.log('Custom blocks:', blocks);\n
  5. Verify array not empty

  6. Check GrapesJS block registration:

  7. Open admin/src/components/GrapesJSEditor.tsx
  8. Verify blocks registered in editor.BlockManager.add()

  9. Clear GrapesJS localStorage:

  10. GrapesJS caches project data
  11. Open DevTools \u2192 Application \u2192 Local Storage
  12. Delete keys starting with gjsProject-
"},{"location":"v2/frontend/pages/admin/page-editor-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/","title":"PangolinPage","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#overview","title":"Overview","text":"

File: admin/src/pages/PangolinPage.tsx Route: /app/tunnel Role Requirements: SUPER_ADMIN

PangolinPage is the tunnel management interface for Changemaker Lite's Pangolin Integration, which provides secure tunneling and public access to the platform via the Newt container. The page provides a complete setup wizard for first-time configuration, resource management for subdomains and services, exit node selection for multi-node setups, and Newt container lifecycle controls.

The page displays three main sections: 1. Status Dashboard: Shows Pangolin API health, configuration state, and Newt container status 2. Setup Wizard: Guides admins through site creation, subnet allocation, and exit node selection 3. Resource Management: Table of tunnel resources with SSL, active status, port, protocol, and access controls

Key Components: - Status card with Descriptions showing configuration and health - Setup form with auto-suggested subnet calculation - Exit node selector (optional for self-hosted setups) - Resource table with edit modal and delete confirmation - Newt container restart button - Credential display with show/hide toggle

"},{"location":"v2/frontend/pages/admin/pangolin-page/#screenshot","title":"Screenshot","text":"

[Screenshot: PangolinPage showing three sections: 1) Status card at top with configuration (Configured=Yes, Healthy, API URL, Newt Container Ready, Org ID, Site ID), 2) Setup wizard (if not configured) with site name input, subnet input, exit node dropdown, and Create button, 3) Resource management table showing domains with SSL, Active, Port, Protocol, Blocked columns and Edit/Delete actions. Setup wizard includes credential display alert with show/hide button after successful setup.]

"},{"location":"v2/frontend/pages/admin/pangolin-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#core-features","title":"Core Features","text":"
  1. Status Monitoring
  2. Pangolin API configuration check (Configured: Yes/No)
  3. Server health check (Healthy/Unreachable)
  4. API URL display with copy button
  5. Newt container status (Ready/Running/Stopped/Not configured)
  6. Organization ID and Site ID display
  7. Auto-refresh on status change

  8. Setup Wizard (First-Time Configuration)

  9. Site name input (defaults to changemaker-{domain})
  10. Subnet allocation with auto-suggestion (calculates next available subnet)
  11. Exit node selection (optional, only shown if exit nodes available)
  12. Online/offline exit node status indicators
  13. Auto-select single online exit node
  14. Create Site + Resources button
  15. Post-setup credential display with show/hide toggle

  16. Credential Management

  17. Shows PANGOLIN_SITE_ID and NEWT_* credentials after setup
  18. Show/Hide credentials button (security)
  19. Copy to Clipboard button
  20. Clear Credentials button
  21. Step-by-step instructions for .env setup
  22. Newt container restart button after credential update

  23. Resource Management

  24. Table showing all tunnel resources (subdomains)
  25. Columns: Name, Domain (copyable), SSL status, Active status, Port, Protocol, Blocked
  26. Edit button opens modal for resource configuration
  27. Delete button with Popconfirm
  28. Sync Resources button (creates missing resources from docker-compose.yml)
  29. Restart Newt button in table header

  30. Resource Editing

  31. Edit modal with form fields:
  32. Update button saves changes to Pangolin API

  33. Exit Node Support

  34. Fetches available exit nodes from Pangolin API
  35. Displays exit node name and location
  36. Shows online/offline status
  37. Filters out offline nodes (with user notice)
  38. Auto-selects if only one online node available
  39. Graceful fallback if no exit nodes (self-hosted setups)

  40. Newt Container Management

  41. Status monitoring (running/stopped/ready)
  42. Restart button in Status card
  43. Restart button in Resource table header
  44. 3-second delay after restart before status check
  45. Success/error messages for restart operations

  46. Subnet Auto-Suggestion

  47. Fetches existing sites from Pangolin API
  48. Parses last subnet (e.g., 100.90.128.2/24)
  49. Suggests next available subnet (increments last octet)
  50. Defaults to 100.90.128.3/24 if no sites exist
  51. Allows manual override if suggested subnet conflicts

  52. Security Features

  53. Credentials hidden by default
  54. Show/Hide toggle for sensitive values
  55. Clear button to remove credentials from screen
  56. Text sanitization for external API data (defense-in-depth)
"},{"location":"v2/frontend/pages/admin/pangolin-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#initial-setup-first-time-configuration","title":"Initial Setup (First-Time Configuration)","text":"
  1. Navigate to page: Admin sidebar \u2192 System \u2192 Tunnel Management
  2. Check status: If \"Configured: No\", see blue info alert
  3. Configure .env: Add PANGOLIN_API_URL, PANGOLIN_API_KEY, PANGOLIN_ORG_ID to .env
  4. Restart API: docker compose restart api
  5. Reload page: Status card shows \"Configured: Yes\"
  6. Fill setup form:
  7. Site Name: changemaker-cmlite.org (or custom)
  8. Subnet: 100.90.128.3/24 (auto-suggested, or override)
  9. Exit Node: Select if available (or leave empty for self-hosted)
  10. Click \"Create Site + Resources\"
  11. Wait for setup: Loading spinner, ~5-10 seconds
  12. View credentials: Success alert shows PANGOLIN_SITE_ID and NEWT_* values
  13. Show credentials: Click \"Show Credentials\" button
  14. Copy to .env: Click \"Copy to Clipboard\" button, paste into .env file
  15. Clear credentials: After copying, click \"Clear Credentials\" (security)
  16. Update .env: Save .env file with new values
  17. Restart Newt: Click \"Restart Newt Container\" button
  18. Verify status: Newt status changes to \"Ready\" (green tag)
"},{"location":"v2/frontend/pages/admin/pangolin-page/#viewing-status","title":"Viewing Status","text":"
  1. Open page: Navigate to /app/tunnel
  2. Check configuration: Status card shows Configured/Healthy/Newt Container status
  3. Verify API URL: Copy URL if needed for external tools
  4. Check Org/Site IDs: Verify correct organization and site selected
  5. Monitor Newt: Check if container is Ready (green) or Stopped (red)
"},{"location":"v2/frontend/pages/admin/pangolin-page/#managing-resources","title":"Managing Resources","text":"
  1. View resources: Scroll to \"Tunnel Resources\" card
  2. Check domains: Each row shows subdomain (e.g., api.cmlite.org)
  3. Verify SSL: Green \"Yes\" tag indicates SSL enabled
  4. Check active status: Green \"Active\" tag = resource enabled
  5. Review ports: Verify proxy port matches docker-compose.yml
  6. Edit resource: Click Edit button for a resource
  7. Modify settings: Change name, protocol, port, SSL, active, or block access
  8. Save changes: Click Update button in modal
  9. Verify update: Table refreshes with new values
"},{"location":"v2/frontend/pages/admin/pangolin-page/#syncing-resources","title":"Syncing Resources","text":"
  1. Add new service: Update docker-compose.yml with new subdomain
  2. Click \"Sync Resources\": Button in table header
  3. Wait for sync: Loading state, ~2-5 seconds
  4. View results: Success message shows {created} created, {skipped} skipped
  5. Check table: New resources appear in table
"},{"location":"v2/frontend/pages/admin/pangolin-page/#restarting-newt-container","title":"Restarting Newt Container","text":"

Scenario 1: After Credential Update 1. Update .env: Add PANGOLIN_SITE_ID and NEWT_* credentials 2. Click \"Restart Newt Container\" in setup wizard alert 3. Wait ~3 seconds: Container takes time to restart 4. Check status: \"Newt Container: Ready\" in status card

Scenario 2: Troubleshooting Connection 1. Notice Newt not ready: Status shows \"Running (Not configured)\" or \"Stopped\" 2. Click \"Restart Newt\" button in Resource table header 3. Wait for restart: Success message appears 4. Verify status: Refresh status after 3 seconds

"},{"location":"v2/frontend/pages/admin/pangolin-page/#deleting-resources","title":"Deleting Resources","text":"
  1. Identify resource: Find resource to delete in table
  2. Click Delete button: Red icon button on right
  3. Read Popconfirm: \"Delete this resource?\"
  4. Confirm deletion: Click OK
  5. Resource removed: Table refreshes, resource no longer shown
"},{"location":"v2/frontend/pages/admin/pangolin-page/#editing-resource-configuration","title":"Editing Resource Configuration","text":"
  1. Click Edit button: Opens Edit Resource modal
  2. Modify fields:
  3. Name: Display name for resource
  4. Protocol: http or https
  5. Proxy Port: Internal container port (e.g., 4000 for API)
  6. Enable SSL: Checkbox for HTTPS
  7. Active: Checkbox to enable/disable resource
  8. Block Access: Checkbox to block public access (maintenance mode)
  9. Click Update: Saves changes to Pangolin API
  10. Close modal: Modal closes automatically on success
  11. Verify changes: Table shows updated values
"},{"location":"v2/frontend/pages/admin/pangolin-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#status-card","title":"Status Card","text":"
<Card title={<><CloudServerOutlined /> Pangolin Tunnel Status</>}>\n  <Descriptions column={{ xs: 1, sm: 2 }} bordered size=\"small\">\n    <Descriptions.Item label=\"Configured\">\n      {isConfigured ? <Tag color=\"success\">Yes</Tag> : <Tag color=\"error\">No</Tag>}\n    </Descriptions.Item>\n    <Descriptions.Item label=\"Server Health\">\n      {isHealthy ? <Tag color=\"success\">Healthy</Tag> : <Tag color=\"error\">Unreachable</Tag>}\n    </Descriptions.Item>\n    <Descriptions.Item label=\"API URL\">\n      <Text copyable>{config?.pangolinApiUrl || 'Not set'}</Text>\n    </Descriptions.Item>\n    <Descriptions.Item label=\"Newt Container\">\n      {newtStatus?.ready ? <Tag color=\"success\">Ready</Tag> : <Tag color=\"error\">Stopped</Tag>}\n    </Descriptions.Item>\n    <Descriptions.Item label=\"Organization ID\">\n      <Text code>{config?.orgId || 'Not set'}</Text>\n    </Descriptions.Item>\n    <Descriptions.Item label=\"Site ID\">\n      <Text code>{config?.siteId || 'Not set'}</Text>\n    </Descriptions.Item>\n  </Descriptions>\n</Card>\n

Responsive: 1 column on mobile, 2 columns on desktop

"},{"location":"v2/frontend/pages/admin/pangolin-page/#setup-form","title":"Setup Form","text":"
<Form form={setupForm} layout=\"vertical\" onFinish={handleSetup}>\n  <Form.Item name=\"siteName\" label=\"Site Name\">\n    <Input placeholder={`changemaker-${config?.domain || 'cmlite.org'}`} />\n  </Form.Item>\n  <Form.Item\n    name=\"subnet\"\n    label=\"Subnet (CIDR notation)\"\n    tooltip=\"Network subnet for this site. Auto-suggested based on existing allocations.\"\n    initialValue={suggestedSubnet}\n  >\n    <Input placeholder=\"100.90.128.3/24\" />\n  </Form.Item>\n  {exitNodes.length > 0 && (\n    <Form.Item\n      name=\"exitNodeId\"\n      label=\"Exit Node (optional)\"\n      tooltip=\"Network exit point for tunneled traffic. Only needed for multi-node Pangolin setups.\"\n    >\n      <Select placeholder=\"Select an exit node (optional)\" allowClear>\n        {exitNodes.map(node => (\n          <Select.Option key={node.exitNodeId} value={node.exitNodeId} disabled={!node.online}>\n            {sanitizeText(node.name)}\n            {node.location && ` (${sanitizeText(node.location)})`}\n            {!node.online && ' [OFFLINE]'}\n          </Select.Option>\n        ))}\n      </Select>\n    </Form.Item>\n  )}\n  <Form.Item>\n    <Button type=\"primary\" htmlType=\"submit\" loading={actionLoading} icon={<RocketOutlined />}>\n      Create Site + Resources\n    </Button>\n  </Form.Item>\n</Form>\n
"},{"location":"v2/frontend/pages/admin/pangolin-page/#credential-display-alert","title":"Credential Display Alert","text":"
<Alert\n  type=\"success\"\n  showIcon\n  closable\n  onClose={() => {\n    setSetupResult(null);\n    setShowCredentials(false);\n  }}\n  message=\"Setup Complete\"\n  description={\n    <div>\n      <Paragraph>\n        <strong>Step 1:</strong> Add these to your <Text code>.env</Text> file:\n      </Paragraph>\n      <Space style={{ marginBottom: 12 }}>\n        <Button\n          size=\"small\"\n          icon={showCredentials ? <EyeInvisibleOutlined /> : <EyeOutlined />}\n          onClick={() => setShowCredentials(!showCredentials)}\n        >\n          {showCredentials ? 'Hide' : 'Show'} Credentials\n        </Button>\n        <Button\n          size=\"small\"\n          icon={<CopyOutlined />}\n          onClick={() => {\n            const text = setupResult.instructions.slice(1, -1).join('\\n');\n            navigator.clipboard.writeText(text);\n            message.success('Copied to clipboard');\n          }}\n        >\n          Copy to Clipboard\n        </Button>\n        <Button\n          size=\"small\"\n          danger\n          onClick={() => {\n            setSetupResult(null);\n            setShowCredentials(false);\n          }}\n        >\n          Clear Credentials\n        </Button>\n      </Space>\n\n      {showCredentials && (\n        <pre style={{ background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 6, fontSize: 12 }}>\n          {setupResult.instructions.slice(1, -1).join('\\n')}\n        </pre>\n      )}\n\n      <Paragraph style={{ marginTop: 16 }}>\n        <strong>Step 2:</strong> After updating .env, restart the Newt container:\n      </Paragraph>\n      <Button\n        type=\"primary\"\n        icon={<SyncOutlined />}\n        loading={restartLoading}\n        onClick={handleRestartNewt}\n      >\n        Restart Newt Container\n      </Button>\n    </div>\n  }\n/>\n
"},{"location":"v2/frontend/pages/admin/pangolin-page/#resource-table","title":"Resource Table","text":"
<Table\n  dataSource={resources}\n  rowKey=\"resourceId\"\n  size=\"small\"\n  pagination={false}\n  columns={[\n    {\n      title: 'Name',\n      dataIndex: 'name',\n    },\n    {\n      title: 'Domain',\n      render: (_, r: PangolinResource) => (\n        <Text copyable>{r.fullDomain || r.subdomain || '(root)'}</Text>\n      ),\n    },\n    {\n      title: 'SSL',\n      dataIndex: 'ssl',\n      render: (ssl: boolean) => ssl ? <Tag color=\"green\">Yes</Tag> : <Tag>No</Tag>,\n    },\n    {\n      title: 'Active',\n      dataIndex: 'active',\n      render: (active: boolean) => active !== false\n        ? <Tag color=\"success\">Active</Tag>\n        : <Tag color=\"error\">Inactive</Tag>,\n    },\n    {\n      title: 'Port',\n      dataIndex: 'proxyPort',\n      render: (port?: number) => port || '80',\n    },\n    {\n      title: 'Protocol',\n      dataIndex: 'protocol',\n      render: (p?: string) => p || 'http',\n    },\n    {\n      title: 'Blocked',\n      dataIndex: 'blockAccess',\n      render: (blocked?: boolean) => blocked ? <Tag color=\"red\">Blocked</Tag> : null,\n    },\n    {\n      title: 'Actions',\n      render: (_, r: PangolinResource) => (\n        <Space>\n          <Button size=\"small\" onClick={() => handleEditResource(r)}>Edit</Button>\n          <Popconfirm title=\"Delete this resource?\" onConfirm={() => handleDeleteResource(r.resourceId)}>\n            <Button size=\"small\" danger icon={<DeleteOutlined />} />\n          </Popconfirm>\n        </Space>\n      ),\n    },\n  ]}\n/>\n
"},{"location":"v2/frontend/pages/admin/pangolin-page/#edit-resource-modal","title":"Edit Resource Modal","text":"
<Modal\n  title=\"Edit Resource\"\n  open={editModalVisible}\n  onCancel={() => {\n    setEditModalVisible(false);\n    setEditingResource(null);\n    editForm.resetFields();\n  }}\n  footer={null}\n  width={600}\n>\n  <Form form={editForm} layout=\"vertical\" onFinish={handleUpdateResource}>\n    <Form.Item label=\"Name\" name=\"name\" rules={[{ required: true }]}>\n      <Input />\n    </Form.Item>\n    <Form.Item label=\"Protocol\" name=\"protocol\">\n      <Input placeholder=\"http\" />\n    </Form.Item>\n    <Form.Item label=\"Proxy Port\" name=\"proxyPort\">\n      <Input type=\"number\" placeholder=\"80\" />\n    </Form.Item>\n    <Form.Item name=\"ssl\" valuePropName=\"checked\">\n      <Checkbox>Enable SSL</Checkbox>\n    </Form.Item>\n    <Form.Item name=\"active\" valuePropName=\"checked\">\n      <Checkbox>Active</Checkbox>\n    </Form.Item>\n    <Form.Item name=\"blockAccess\" valuePropName=\"checked\">\n      <Checkbox>Block Access</Checkbox>\n    </Form.Item>\n    <Form.Item>\n      <Space>\n        <Button type=\"primary\" htmlType=\"submit\" loading={actionLoading}>Update</Button>\n        <Button onClick={() => setEditModalVisible(false)}>Cancel</Button>\n      </Space>\n    </Form.Item>\n  </Form>\n</Modal>\n
"},{"location":"v2/frontend/pages/admin/pangolin-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#local-state","title":"Local State","text":"

Status & Config:

const [status, setStatus] = useState<PangolinStatus | null>(null);\nconst [config, setConfig] = useState<PangolinConfig | null>(null);\nconst [resources, setResources] = useState<PangolinResource[]>([]);\nconst [newtStatus, setNewtStatus] = useState<PangolinNewtStatus | null>(null);\nconst [loading, setLoading] = useState(true);\n

Setup Wizard State:

const [setupResult, setSetupResult] = useState<Record<string, unknown> | null>(null);\nconst [suggestedSubnet, setSuggestedSubnet] = useState<string>('100.90.128.3/24');\nconst [exitNodes, setExitNodes] = useState<PangolinExitNode[]>([]);\nconst [exitNodesLoading, setExitNodesLoading] = useState(false);\nconst [showCredentials, setShowCredentials] = useState(false);\nconst [setupForm] = Form.useForm();\n

Resource Management State:

const [editModalVisible, setEditModalVisible] = useState(false);\nconst [editingResource, setEditingResource] = useState<PangolinResource | null>(null);\nconst [editForm] = Form.useForm();\nconst [actionLoading, setActionLoading] = useState(false);\nconst [restartLoading, setRestartLoading] = useState(false);\nconst [newtLoading, setNewtLoading] = useState(false);\n

"},{"location":"v2/frontend/pages/admin/pangolin-page/#data-fetching","title":"Data Fetching","text":"

Fetch Status + Config:

const fetchData = useCallback(async () => {\n  setLoading(true);\n  try {\n    const [statusRes, configRes] = await Promise.all([\n      api.get<PangolinStatus>('/pangolin/status'),\n      api.get<PangolinConfig>('/pangolin/config'),\n    ]);\n    setStatus(statusRes.data);\n    setConfig(configRes.data);\n\n    if (statusRes.data.configured) {\n      try {\n        const resourcesRes = await api.get<{ resources: PangolinResource[] }>('/pangolin/resources');\n        setResources(resourcesRes.data.resources);\n      } catch {\n        // Resources may not load if site isn't set up\n      }\n    }\n  } catch {\n    message.error('Failed to load Pangolin status');\n  } finally {\n    setLoading(false);\n  }\n}, [message]);\n

Fetch Newt Status:

const fetchNewtStatus = useCallback(async () => {\n  if (!status?.newtConfigured) return;\n\n  setNewtLoading(true);\n  try {\n    const res = await api.get<PangolinNewtStatus>('/pangolin/newt-status');\n    setNewtStatus(res.data);\n  } catch {\n    // Silently fail - status card will show \"unknown\"\n  } finally {\n    setNewtLoading(false);\n  }\n}, [status?.newtConfigured]);\n

Fetch Exit Nodes:

useEffect(() => {\n  if (status?.configured && !config?.siteId) {\n    setExitNodesLoading(true);\n    api.get<{ exitNodes: PangolinExitNode[] }>('/pangolin/exit-nodes')\n      .then(res => {\n        setExitNodes(res.data.exitNodes);\n\n        // Auto-select if only one ONLINE exit node available\n        const onlineNodes = res.data.exitNodes.filter(n => n.online);\n        if (onlineNodes.length === 1 && onlineNodes[0]) {\n          setupForm.setFieldsValue({ exitNodeId: onlineNodes[0].exitNodeId });\n        }\n      })\n      .catch(() => {\n        // Exit nodes not available - OK for self-hosted setups\n      })\n      .finally(() => setExitNodesLoading(false));\n  }\n}, [status?.configured, config?.siteId, setupForm]);\n

"},{"location":"v2/frontend/pages/admin/pangolin-page/#subnet-auto-suggestion","title":"Subnet Auto-Suggestion","text":"

Helper Function:

const suggestNextSubnet = (sites: PangolinSite[]): string => {\n  if (!sites || sites.length === 0) {\n    return '100.90.128.0/24'; // Default first subnet\n  }\n\n  const subnets = sites\n    .map(s => s.address || s.subnet)\n    .filter(Boolean)\n    .sort();\n\n  if (subnets.length === 0) {\n    return '100.90.128.0/24';\n  }\n\n  const lastSubnet = subnets[subnets.length - 1];\n  const match = lastSubnet.match(/^100\\.90\\.128\\.(\\d+)\\/24$/);\n\n  if (match && match[1]) {\n    const lastOctet = parseInt(match[1], 10);\n    const nextOctet = lastOctet + 1;\n    if (nextOctet <= 255) {\n      return `100.90.128.${nextOctet}/24`;\n    }\n  }\n\n  return '100.90.128.3/24'; // Fallback\n};\n

Fetch and Suggest:

useEffect(() => {\n  if (status?.configured && !config?.siteId) {\n    api.get<{ sites: PangolinSite[] }>('/pangolin/sites')\n      .then(res => {\n        const suggested = suggestNextSubnet(res.data.sites);\n        setSuggestedSubnet(suggested);\n        setupForm.setFieldsValue({ subnet: suggested });\n      })\n      .catch(() => {\n        setupForm.setFieldsValue({ subnet: '100.90.128.3/24' });\n      });\n  }\n}, [status?.configured, config?.siteId, setupForm]);\n

"},{"location":"v2/frontend/pages/admin/pangolin-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#endpoints-used","title":"Endpoints Used","text":"

GET /pangolin/status - Check configuration and health

const { data } = await api.get<PangolinStatus>('/pangolin/status');\n

Response:

{\n  \"configured\": true,\n  \"healthy\": true,\n  \"newtConfigured\": true\n}\n

GET /pangolin/config - Fetch Pangolin configuration

const { data } = await api.get<PangolinConfig>('/pangolin/config');\n

Response:

{\n  \"pangolinApiUrl\": \"https://api.bnkserve.org/v1\",\n  \"orgId\": \"org_abc123\",\n  \"siteId\": \"site_xyz789\",\n  \"domain\": \"cmlite.org\"\n}\n

GET /pangolin/resources - List all tunnel resources

const { data } = await api.get<{ resources: PangolinResource[] }>('/pangolin/resources');\n

Response:

{\n  \"resources\": [\n    {\n      \"resourceId\": \"res_1\",\n      \"name\": \"API\",\n      \"subdomain\": \"api\",\n      \"fullDomain\": \"api.cmlite.org\",\n      \"ssl\": true,\n      \"active\": true,\n      \"proxyPort\": 4000,\n      \"protocol\": \"http\",\n      \"blockAccess\": false\n    }\n  ]\n}\n

POST /pangolin/setup - Create site and resources

const { data } = await api.post('/pangolin/setup', {\n  siteName: 'changemaker-cmlite.org',\n  subnet: '100.90.128.3/24',\n  exitNodeId: 'exit_node_123'  // Optional\n});\n

Response:

{\n  \"siteId\": \"site_xyz789\",\n  \"instructions\": [\n    \"# Add these to your .env file:\",\n    \"PANGOLIN_SITE_ID=site_xyz789\",\n    \"NEWT_ID=newt_abc456\",\n    \"NEWT_SECRET=secret_def789\",\n    \"# Then restart the Newt container\"\n  ]\n}\n

POST /pangolin/sync - Sync resources from docker-compose.yml

const { data } = await api.post<{ created: number; skipped: number; errors: number }>('/pangolin/sync');\n

Response:

{\n  \"created\": 3,\n  \"skipped\": 5,\n  \"errors\": 0\n}\n

PUT /pangolin/resource/:resourceId - Update resource

await api.put(`/pangolin/resource/${resourceId}`, {\n  name: 'Updated API',\n  protocol: 'http',\n  proxyPort: 4000,\n  ssl: true,\n  active: true,\n  blockAccess: false\n});\n

DELETE /pangolin/resource/:resourceId - Delete resource

await api.delete(`/pangolin/resource/${resourceId}`);\n

POST /pangolin/newt-restart - Restart Newt container

await api.post('/pangolin/newt-restart');\n

GET /pangolin/newt-status - Get Newt container status

const { data } = await api.get<PangolinNewtStatus>('/pangolin/newt-status');\n

Response:

{\n  \"containerRunning\": true,\n  \"ready\": true\n}\n

GET /pangolin/sites - List all sites (for subnet suggestion)

const { data } = await api.get<{ sites: PangolinSite[] }>('/pangolin/sites');\n

Response:

{\n  \"sites\": [\n    {\n      \"siteId\": \"site_1\",\n      \"name\": \"changemaker-dev\",\n      \"address\": \"100.90.128.2/24\"\n    }\n  ]\n}\n

GET /pangolin/exit-nodes - List available exit nodes

const { data } = await api.get<{ exitNodes: PangolinExitNode[] }>('/pangolin/exit-nodes');\n

Response:

{\n  \"exitNodes\": [\n    {\n      \"exitNodeId\": \"exit_1\",\n      \"name\": \"US East\",\n      \"location\": \"New York\",\n      \"online\": true\n    },\n    {\n      \"exitNodeId\": \"exit_2\",\n      \"name\": \"US West\",\n      \"location\": \"San Francisco\",\n      \"online\": false\n    }\n  ]\n}\n

"},{"location":"v2/frontend/pages/admin/pangolin-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#subnet-auto-suggestion-logic","title":"Subnet Auto-Suggestion Logic","text":"
const suggestNextSubnet = (sites: PangolinSite[]): string => {\n  if (!sites || sites.length === 0) {\n    return '100.90.128.0/24';\n  }\n\n  // Get all existing subnets and sort\n  const subnets = sites\n    .map(s => s.address || s.subnet)\n    .filter(Boolean)\n    .sort();\n\n  if (subnets.length === 0) return '100.90.128.0/24';\n\n  // Parse last subnet to extract octet\n  const lastSubnet = subnets[subnets.length - 1];\n  const match = lastSubnet.match(/^100\\.90\\.128\\.(\\d+)\\/24$/);\n\n  if (match && match[1]) {\n    const lastOctet = parseInt(match[1], 10);\n    const nextOctet = lastOctet + 1;\n\n    if (nextOctet <= 255) {\n      return `100.90.128.${nextOctet}/24`;\n    }\n  }\n\n  return '100.90.128.3/24';\n};\n\n// Example usage:\n// Sites: [{ address: '100.90.128.2/24' }, { address: '100.90.128.3/24' }]\n// Returns: '100.90.128.4/24'\n
"},{"location":"v2/frontend/pages/admin/pangolin-page/#exit-node-auto-selection","title":"Exit Node Auto-Selection","text":"
const onlineNodes = exitNodes.filter(n => n.online);\n\nif (onlineNodes.length === 1 && onlineNodes[0]) {\n  // Auto-select the only online node\n  setupForm.setFieldsValue({ exitNodeId: onlineNodes[0].exitNodeId });\n} else if (onlineNodes.length > 1 && onlineNodes.length < exitNodes.length) {\n  // Some nodes offline, warn user\n  message.info('Some exit nodes are offline. Select from available nodes.');\n}\n
"},{"location":"v2/frontend/pages/admin/pangolin-page/#text-sanitization-for-external-api-data","title":"Text Sanitization for External API Data","text":"
const sanitizeText = (text: string | undefined): string => {\n  if (!text) return '';\n  return text.replace(/[<>'\"&]/g, (char) => {\n    const escapeMap: Record<string, string> = {\n      '<': '&lt;',\n      '>': '&gt;',\n      \"'\": '&#39;',\n      '\"': '&quot;',\n      '&': '&amp;',\n    };\n    return escapeMap[char] || char;\n  });\n};\n\n// Usage in exit node options\n<Select.Option value={node.exitNodeId}>\n  {sanitizeText(node.name)}\n  {node.location && ` (${sanitizeText(node.location)})`}\n</Select.Option>\n

Why: Defense-in-depth against XSS if Pangolin API returns malicious data.

"},{"location":"v2/frontend/pages/admin/pangolin-page/#credential-showhide-pattern","title":"Credential Show/Hide Pattern","text":"
const [showCredentials, setShowCredentials] = useState(false);\n\n<Button\n  icon={showCredentials ? <EyeInvisibleOutlined /> : <EyeOutlined />}\n  onClick={() => setShowCredentials(!showCredentials)}\n>\n  {showCredentials ? 'Hide' : 'Show'} Credentials\n</Button>\n\n{showCredentials && (\n  <pre>{setupResult.instructions.slice(1, -1).join('\\n')}</pre>\n)}\n

Security: Credentials hidden by default, require user action to view.

"},{"location":"v2/frontend/pages/admin/pangolin-page/#delayed-status-check-after-restart","title":"Delayed Status Check After Restart","text":"
const handleRestartNewt = async () => {\n  setRestartLoading(true);\n  try {\n    await api.post('/pangolin/newt-restart');\n    message.success('Newt container restarted successfully. Checking status...');\n\n    // Poll status after restart (container takes a few seconds to start)\n    setTimeout(() => {\n      fetchNewtStatus();\n    }, 3000);\n  } catch (err: unknown) {\n    const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Failed to restart container';\n    message.error(msg);\n  } finally {\n    setRestartLoading(false);\n  }\n};\n

Why 3 seconds: Newt container needs time to fully start before status check succeeds.

"},{"location":"v2/frontend/pages/admin/pangolin-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#parallel-api-calls-on-mount","title":"Parallel API Calls on Mount","text":"
const [statusRes, configRes] = await Promise.all([\n  api.get('/pangolin/status'),\n  api.get('/pangolin/config'),\n]);\n

Benefit: Loads status and config simultaneously (faster than sequential).

"},{"location":"v2/frontend/pages/admin/pangolin-page/#optional-newt-status-fetch","title":"Optional Newt Status Fetch","text":"
const fetchNewtStatus = useCallback(async () => {\n  if (!status?.newtConfigured) return; // Don't check if not configured\n  // ... fetch logic\n}, [status?.newtConfigured]);\n

Benefit: Avoids unnecessary API call when Newt not configured.

"},{"location":"v2/frontend/pages/admin/pangolin-page/#graceful-exit-node-failure","title":"Graceful Exit Node Failure","text":"
api.get('/pangolin/exit-nodes')\n  .catch(() => {\n    // Don't show error messages\n    // Exit nodes not available - OK for self-hosted setups\n  });\n

Benefit: Page works without exit nodes (self-hosted Pangolin doesn't use them).

"},{"location":"v2/frontend/pages/admin/pangolin-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#status-card-columns","title":"Status Card Columns","text":"
<Descriptions column={{ xs: 1, sm: 2 }}>\n

Mobile (< 576px): 1 column (stacked) Desktop (\u2265 576px): 2 columns (side-by-side)

"},{"location":"v2/frontend/pages/admin/pangolin-page/#form-layout","title":"Form Layout","text":"
<Form layout=\"vertical\">\n

Vertical layout: Label above input (works well on all screen sizes).

"},{"location":"v2/frontend/pages/admin/pangolin-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#button-labels","title":"Button Labels","text":"

All buttons have text labels (not icon-only):

<Button icon={<SyncOutlined />}>Sync Resources</Button>\n<Button icon={<SaveOutlined />}>Update</Button>\n

"},{"location":"v2/frontend/pages/admin/pangolin-page/#form-tooltips","title":"Form Tooltips","text":"
<Form.Item\n  tooltip=\"Network subnet for this site. Auto-suggested based on existing allocations.\"\n>\n

Provides context without cluttering label.

"},{"location":"v2/frontend/pages/admin/pangolin-page/#popconfirm-for-destructive-actions","title":"Popconfirm for Destructive Actions","text":"
<Popconfirm title=\"Delete this resource?\" onConfirm={handleDelete}>\n  <Button danger />\n</Popconfirm>\n

Prevents accidental deletion.

"},{"location":"v2/frontend/pages/admin/pangolin-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#configured-no-status","title":"\"Configured: No\" Status","text":"

Cause: Missing environment variables

Solution:

# Add to .env\nPANGOLIN_API_URL=https://api.bnkserve.org/v1\nPANGOLIN_API_KEY=your_api_key_here\nPANGOLIN_ORG_ID=org_abc123\n\n# Restart API\ndocker compose restart api\n

"},{"location":"v2/frontend/pages/admin/pangolin-page/#server-health-unreachable","title":"\"Server Health: Unreachable\"","text":"

Causes: 1. Pangolin API server down 2. Incorrect API URL 3. Network connectivity issue

Debug:

# Test API URL directly\ncurl -H \"Authorization: Bearer $PANGOLIN_API_KEY\" \\\n  https://api.bnkserve.org/v1/status\n\n# Check API logs\ndocker compose logs -f api | grep pangolin\n

"},{"location":"v2/frontend/pages/admin/pangolin-page/#setup-fails-with-subnet-already-in-use","title":"Setup Fails with \"Subnet already in use\"","text":"

Cause: Suggested subnet conflicts with existing site

Solution: 1. Manually override subnet in form (e.g., 100.90.128.5/24) 2. Try again

"},{"location":"v2/frontend/pages/admin/pangolin-page/#newt-container-shows-stopped","title":"Newt Container Shows \"Stopped\"","text":"

Causes: 1. Container crashed 2. Missing NEWT_ID or NEWT_SECRET in .env 3. Wrong credentials

Solutions:

# Check container logs\ndocker compose logs newt\n\n# Verify credentials in .env\ncat .env | grep NEWT\n\n# Restart container\ndocker compose restart newt\n\n# Or use UI button\n

"},{"location":"v2/frontend/pages/admin/pangolin-page/#resources-not-syncing","title":"Resources Not Syncing","text":"

Symptoms: - Sync button does nothing - New resources not created

Causes: 1. docker-compose.yml not updated 2. Subdomain naming mismatch 3. API error

Debug:

# Check API logs during sync\ndocker compose logs -f api\n\n# Verify docker-compose.yml has subdomain labels\ncat docker-compose.yml | grep subdomain\n

"},{"location":"v2/frontend/pages/admin/pangolin-page/#credentials-not-showing-after-setup","title":"Credentials Not Showing After Setup","text":"

Cause: setupResult state is null

Debug:

console.log('Setup result:', setupResult);\n

If null: Setup API call failed or returned unexpected format.

"},{"location":"v2/frontend/pages/admin/pangolin-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#backend-integration","title":"Backend Integration","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#features_1","title":"Features","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#deployment","title":"Deployment","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#troubleshooting_1","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#user-guides","title":"User Guides","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#external-resources","title":"External Resources","text":""},{"location":"v2/frontend/pages/admin/representatives-page/","title":"RepresentativesPage","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#overview","title":"Overview","text":"

The RepresentativesPage provides administrative management of the representative cache system that powers the Influence module's postal code lookup functionality. It allows administrators to view cached representatives, search by postal code to populate the cache, clear stale cache entries, and monitor cache statistics. The page integrates with the Represent API (represent.opennorth.ca) to fetch Canadian elected officials.

Route: /app/influence/representatives Component: admin/src/pages/RepresentativesPage.tsx (387 lines) Auth Required: Yes (SUPER_ADMIN or INFLUENCE_ADMIN role recommended) Layout: AppLayout Backend Module: api/src/modules/influence/representatives/

"},{"location":"v2/frontend/pages/admin/representatives-page/#screenshot","title":"Screenshot","text":"

[Screenshot: RepresentativesPage with three statistics cards at top showing \"Cached Representatives: 245\", \"Unique Postal Codes: 89\", and \"Avg Reps per Postal: 2.8\". Below are two input fields side-by-side: \"Search Representatives\" and \"Postal Code Filter\", both with search icons. Below that is a table with columns: Name (sortable), Office (sortable), Level (colored tag), Party (colored tag), District Name, Email, and Actions. Each row has View and Delete action buttons. At the bottom is pagination showing \"1-10 of 245 representatives\". Top right corner has \"Lookup New Postal Code\" primary button and \"Clear All Cache\" danger button. A right-side drawer is open showing \"Representative Details\" with photo, contact info, and social media links.]

"},{"location":"v2/frontend/pages/admin/representatives-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#looking-up-representatives-for-a-postal-code","title":"Looking Up Representatives for a Postal Code","text":"
  1. Navigate to /app/influence/representatives
  2. Click \"Lookup New Postal Code\" button (top right)
  3. Modal appears: \"Lookup Representatives\"
  4. Enter a valid Canadian postal code (e.g., \"K1A 0A9\")
  5. Click \"Lookup\" button
  6. Wait for API request to Represent API
  7. Success scenarios:
  8. New representatives found: Success message \"Found 3 representatives for K1A 0A9 and cached them\"
  9. Already cached: Info message \"Representatives for K1A 0A9 are already cached\"
  10. No representatives found: Warning message \"No representatives found for K1A 0A9\"
  11. Table automatically refreshes to show newly cached representatives
  12. Statistics cards update to reflect new cache size
"},{"location":"v2/frontend/pages/admin/representatives-page/#searching-for-cached-representatives","title":"Searching for Cached Representatives","text":"
  1. Locate \"Search Representatives\" input field (below statistics cards)
  2. Start typing search query (e.g., \"John Smith\")
  3. Search automatically triggers after 300ms pause (debounce)
  4. Table filters to show matching representatives
  5. Matches on: name, office title, political party, district name
  6. Clear search by clicking X icon or deleting text
"},{"location":"v2/frontend/pages/admin/representatives-page/#filtering-by-postal-code","title":"Filtering by Postal Code","text":"
  1. Locate \"Postal Code Filter\" input field (next to search field)
  2. Start typing postal code (e.g., \"K1A\")
  3. Filter automatically triggers after 300ms pause (debounce)
  4. Table shows only representatives associated with that postal code
  5. Clear filter by clicking X icon or deleting text
  6. Can combine with search filter for more specific results
"},{"location":"v2/frontend/pages/admin/representatives-page/#viewing-representative-details","title":"Viewing Representative Details","text":"
  1. Locate representative in table
  2. Click \"View\" button in Actions column
  3. Right-side drawer opens: \"Representative Details\"
  4. View information sections:
  5. Photo: Representative's portrait (if available)
  6. Basic Info: Name, political party, government level
  7. Office: Office title, district name
  8. Contact: Email, phone numbers (if available)
  9. Addresses: Office address, mailing address (if available)
  10. Social Media: Twitter, Facebook, website links (if available)
  11. Other Data: Custom fields from Represent API
  12. Click Close button or drawer overlay to dismiss
"},{"location":"v2/frontend/pages/admin/representatives-page/#deleting-cached-representatives","title":"Deleting Cached Representatives","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#individual-deletion","title":"Individual Deletion","text":"
  1. Locate representative in table
  2. Click \"Delete\" button in Actions column (red text)
  3. Confirmation modal appears: \"Are you sure you want to delete this representative from cache?\"
  4. Click \"Delete\" to confirm (or \"Cancel\" to abort)
  5. Success message: \"Representative deleted from cache\"
  6. Representative removed from table immediately
  7. Statistics cards update to reflect reduced cache size
"},{"location":"v2/frontend/pages/admin/representatives-page/#bulk-cache-clearing","title":"Bulk Cache Clearing","text":"
  1. Click \"Clear All Cache\" button (top right, danger style)
  2. Confirmation modal appears: \"Are you sure you want to clear the entire representative cache? This will delete all 245 cached representatives.\"
  3. Enter confirmation phrase if prompted (optional safety measure)
  4. Click \"Clear Cache\" to confirm (or \"Cancel\" to abort)
  5. Loading indicator appears
  6. All cache entries deleted from database
  7. Success message: \"Cache cleared successfully. Deleted 245 representatives.\"
  8. Table refreshes to show empty state
  9. Statistics cards reset to zero
"},{"location":"v2/frontend/pages/admin/representatives-page/#sorting-the-table","title":"Sorting the Table","text":"
  1. Identify sortable columns (Name, Office, Level, Party, District Name)
  2. Click column header to sort ascending (\u2191 arrow appears)
  3. Click again to sort descending (\u2193 arrow appears)
  4. Click third time to remove sorting (no arrow)
  5. Default sort: Name ascending
  6. Can combine with search/filter (sorted results only)
"},{"location":"v2/frontend/pages/admin/representatives-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#ant-design-components-used","title":"Ant Design Components Used","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#table-structure","title":"Table Structure","text":"
const columns: ColumnsType<Representative> = [\n  {\n    title: 'Name',\n    dataIndex: 'name',\n    key: 'name',\n    sorter: (a, b) => a.name.localeCompare(b.name),\n    width: 200,\n  },\n  {\n    title: 'Office',\n    dataIndex: 'officeTitle',\n    key: 'officeTitle',\n    sorter: (a, b) => (a.officeTitle || '').localeCompare(b.officeTitle || ''),\n    width: 200,\n  },\n  {\n    title: 'Level',\n    dataIndex: 'level',\n    key: 'level',\n    width: 120,\n    render: (level: string) => {\n      const colorMap: Record<string, string> = {\n        Federal: 'red',\n        Provincial: 'blue',\n        Municipal: 'green',\n      };\n      return <Tag color={colorMap[level] || 'default'}>{level}</Tag>;\n    },\n    sorter: (a, b) => a.level.localeCompare(b.level),\n  },\n  {\n    title: 'Party',\n    dataIndex: 'politicalParty',\n    key: 'politicalParty',\n    width: 150,\n    render: (party: string | null) => {\n      if (!party) return <Text type=\"secondary\">\u2014</Text>;\n      return <Tag color=\"default\">{party}</Tag>;\n    },\n    sorter: (a, b) => (a.politicalParty || '').localeCompare(b.politicalParty || ''),\n  },\n  {\n    title: 'District Name',\n    dataIndex: 'districtName',\n    key: 'districtName',\n    sorter: (a, b) => (a.districtName || '').localeCompare(b.districtName || ''),\n  },\n  {\n    title: 'Email',\n    dataIndex: 'email',\n    key: 'email',\n    width: 200,\n    render: (email: string | null) => {\n      if (!email) return <Text type=\"secondary\">\u2014</Text>;\n      return <a href={`mailto:${email}`}>{email}</a>;\n    },\n  },\n  {\n    title: 'Actions',\n    key: 'actions',\n    width: 140,\n    fixed: 'right',\n    render: (_: unknown, record: Representative) => (\n      <Space size=\"small\">\n        <Button\n          size=\"small\"\n          type=\"link\"\n          icon={<EyeOutlined />}\n          onClick={() => handleViewDetails(record)}\n        >\n          View\n        </Button>\n        <Button\n          size=\"small\"\n          type=\"link\"\n          danger\n          icon={<DeleteOutlined />}\n          onClick={() => handleDeleteConfirm(record)}\n        >\n          Delete\n        </Button>\n      </Space>\n    ),\n  },\n];\n

Column Features: - Name: Primary identifier, sortable, 200px width - Office: Job title (e.g., \"Member of Parliament\"), sortable, 200px width - Level: Government level with color-coded tags (Federal=red, Provincial=blue, Municipal=green), sortable, 120px width - Party: Political party affiliation (e.g., \"Liberal Party of Canada\"), sortable, 150px width, nullable (shows \"\u2014\" if null) - District Name: Electoral district (e.g., \"Ottawa Centre\"), sortable - Email: Contact email with mailto: link, 200px width, nullable (shows \"\u2014\" if null) - Actions: View and Delete buttons, 140px width, fixed right

"},{"location":"v2/frontend/pages/admin/representatives-page/#statistics-cards","title":"Statistics Cards","text":"
{stats && (\n  <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>\n    <Col xs={24} sm={8}>\n      <Card size=\"small\">\n        <Statistic\n          title=\"Cached Representatives\"\n          value={stats.totalRepresentatives}\n          prefix={<TeamOutlined />}\n        />\n      </Card>\n    </Col>\n    <Col xs={24} sm={8}>\n      <Card size=\"small\">\n        <Statistic\n          title=\"Unique Postal Codes\"\n          value={stats.uniquePostalCodes}\n          prefix={<EnvironmentOutlined />}\n        />\n      </Card>\n    </Col>\n    <Col xs={24} sm={8}>\n      <Card size=\"small\">\n        <Statistic\n          title=\"Avg Reps per Postal\"\n          value={stats.avgRepsPerPostal}\n          precision={1}\n          prefix={<LineChartOutlined />}\n        />\n      </Card>\n    </Col>\n  </Row>\n)}\n

Responsive Grid: - Desktop (sm+): 3 cards side-by-side (8 columns each = 8/24 = \u2153 width) - Mobile (xs): Stacked cards (24 columns = full width) - Gutter: 16px horizontal and vertical spacing

"},{"location":"v2/frontend/pages/admin/representatives-page/#detail-drawer","title":"Detail Drawer","text":"
<Drawer\n  title=\"Representative Details\"\n  placement=\"right\"\n  width={600}\n  open={detailDrawerOpen}\n  onClose={() => setDetailDrawerOpen(false)}\n>\n  {selectedRep && (\n    <Space direction=\"vertical\" size=\"large\" style={{ width: '100%' }}>\n      {selectedRep.photoUrl && (\n        <Image\n          src={selectedRep.photoUrl}\n          alt={selectedRep.name}\n          width={200}\n          style={{ borderRadius: 8 }}\n        />\n      )}\n      <Descriptions column={1} bordered>\n        <Descriptions.Item label=\"Name\">{selectedRep.name}</Descriptions.Item>\n        <Descriptions.Item label=\"Office Title\">\n          {selectedRep.officeTitle || '\u2014'}\n        </Descriptions.Item>\n        <Descriptions.Item label=\"Government Level\">\n          <Tag color={getLevelColor(selectedRep.level)}>{selectedRep.level}</Tag>\n        </Descriptions.Item>\n        <Descriptions.Item label=\"Political Party\">\n          {selectedRep.politicalParty || '\u2014'}\n        </Descriptions.Item>\n        <Descriptions.Item label=\"District Name\">\n          {selectedRep.districtName || '\u2014'}\n        </Descriptions.Item>\n        <Descriptions.Item label=\"Email\">\n          {selectedRep.email ? <a href={`mailto:${selectedRep.email}`}>{selectedRep.email}</a> : '\u2014'}\n        </Descriptions.Item>\n        {selectedRep.phone && (\n          <Descriptions.Item label=\"Phone\">\n            <a href={`tel:${selectedRep.phone}`}>{selectedRep.phone}</a>\n          </Descriptions.Item>\n        )}\n        {selectedRep.fax && (\n          <Descriptions.Item label=\"Fax\">{selectedRep.fax}</Descriptions.Item>\n        )}\n        {selectedRep.officeAddress && (\n          <Descriptions.Item label=\"Office Address\">\n            {selectedRep.officeAddress}\n          </Descriptions.Item>\n        )}\n        {selectedRep.mailingAddress && (\n          <Descriptions.Item label=\"Mailing Address\">\n            {selectedRep.mailingAddress}\n          </Descriptions.Item>\n        )}\n        {selectedRep.personalUrl && (\n          <Descriptions.Item label=\"Website\">\n            <a href={selectedRep.personalUrl} target=\"_blank\" rel=\"noopener noreferrer\">\n              {selectedRep.personalUrl}\n            </a>\n          </Descriptions.Item>\n        )}\n        {selectedRep.socialMedia && (\n          <Descriptions.Item label=\"Social Media\">\n            {selectedRep.socialMedia.twitter && (\n              <a href={selectedRep.socialMedia.twitter} target=\"_blank\" rel=\"noopener noreferrer\">\n                Twitter\n              </a>\n            )}\n            {selectedRep.socialMedia.facebook && (\n              <>\n                {' | '}\n                <a href={selectedRep.socialMedia.facebook} target=\"_blank\" rel=\"noopener noreferrer\">\n                  Facebook\n                </a>\n              </>\n            )}\n          </Descriptions.Item>\n        )}\n        {selectedRep.otherData && Object.keys(selectedRep.otherData).length > 0 && (\n          <Descriptions.Item label=\"Other Data\">\n            <pre style={{ fontSize: 12, margin: 0 }}>\n              {JSON.stringify(selectedRep.otherData, null, 2)}\n            </pre>\n          </Descriptions.Item>\n        )}\n      </Descriptions>\n    </Space>\n  )}\n</Drawer>\n

Drawer Features: - Width: 600px on desktop, full-width on mobile - Placement: Right side slide-in - Photo: Representative portrait (if available from Represent API) - Contact Links: Clickable mailto: and tel: links for email and phone - External Links: Website and social media with target=\"_blank\" (opens new tab) - Other Data: JSON dump of any custom fields from Represent API (formatted with

)"},{"location":"v2/frontend/pages/admin/representatives-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#local-state-no-zustand-store","title":"Local State (No Zustand Store)","text":"
// Data state\nconst [representatives, setRepresentatives] = useState<Representative[]>([]);\nconst [stats, setStats] = useState<CacheStats | null>(null);\nconst [loading, setLoading] = useState(false);\n\n// Filter state\nconst [search, setSearch] = useState('');\nconst [postalCodeFilter, setPostalCodeFilter] = useState('');\nconst [levelFilter, setLevelFilter] = useState<string | undefined>(undefined);\n\n// Pagination state\nconst [pagination, setPagination] = useState({\n  current: 1,\n  pageSize: 10,\n  total: 0,\n});\n\n// UI state\nconst [detailDrawerOpen, setDetailDrawerOpen] = useState(false);\nconst [selectedRep, setSelectedRep] = useState<Representative | null>(null);\nconst [lookupModalOpen, setLookupModalOpen] = useState(false);\n\n// Debounce timers\nconst searchTimerRef = useRef<NodeJS.Timeout | null>(null);\nconst postalTimerRef = useRef<NodeJS.Timeout | null>(null);\n
\n

No Global State:

\n

This page does NOT use Zustand stores. Representative cache data is fetched directly from the API on mount and after mutations. This is appropriate because:\n- Representative cache is admin-only data (not needed globally)\n- Data changes infrequently (only on manual lookup/delete)\n- No need to share state between pages\n- Simpler architecture without store overhead

"},{"location":"v2/frontend/pages/admin/representatives-page/#debounced-search-pattern","title":"Debounced Search Pattern","text":"
const handleSearch = (value: string) => {\n  // Clear existing timer\n  if (searchTimerRef.current) {\n    clearTimeout(searchTimerRef.current);\n  }\n\n  // Set new timer\n  searchTimerRef.current = setTimeout(() => {\n    setSearch(value);\n    setPagination((prev) => ({ ...prev, current: 1 })); // Reset to page 1\n  }, 300);\n};\n\nconst handlePostalCodeFilterChange = (value: string) => {\n  // Clear existing timer\n  if (postalTimerRef.current) {\n    clearTimeout(postalTimerRef.current);\n  }\n\n  // Set new timer\n  postalTimerRef.current = setTimeout(() => {\n    setPostalCodeFilter(value);\n    setPagination((prev) => ({ ...prev, current: 1 })); // Reset to page 1\n  }, 300);\n};\n
\n

Why 300ms Debounce?

\n"},{"location":"v2/frontend/pages/admin/representatives-page/#usecallback-optimization","title":"useCallback Optimization","text":"
const loadRepresentatives = useCallback(async () => {\n  setLoading(true);\n  try {\n    const params: Record<string, unknown> = {\n      page: pagination.current,\n      limit: pagination.pageSize,\n    };\n\n    if (search) params.search = search;\n    if (postalCodeFilter) params.postalCode = postalCodeFilter;\n    if (levelFilter) params.level = levelFilter;\n\n    const { data } = await api.get<{\n      data: Representative[];\n      pagination: { total: number };\n    }>('/representatives', { params });\n\n    setRepresentatives(data.data);\n    setPagination((prev) => ({\n      ...prev,\n      total: data.pagination.total,\n    }));\n  } catch (error) {\n    message.error('Failed to load representatives');\n  } finally {\n    setLoading(false);\n  }\n}, [pagination.current, pagination.pageSize, search, postalCodeFilter, levelFilter]);\n\nconst loadStats = useCallback(async () => {\n  try {\n    const { data } = await api.get<CacheStats>('/representatives/stats');\n    setStats(data);\n  } catch (error) {\n    message.error('Failed to load statistics');\n  }\n}, []);\n\nuseEffect(() => {\n  loadRepresentatives();\n}, [loadRepresentatives]);\n\nuseEffect(() => {\n  loadStats();\n}, [loadStats]);\n
\n

Why useCallback?

\n"},{"location":"v2/frontend/pages/admin/representatives-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#endpoints-used","title":"Endpoints Used","text":"Method\nEndpoint\nPurpose\nAuth\n\n\n\n\nGET\n/api/representatives\nList cached representatives\nRequired\n\n\nGET\n/api/representatives/stats\nCache statistics\nRequired\n\n\nPOST\n/api/representatives/lookup/:postalCode\nLookup by postal code\nRequired\n\n\nDELETE\n/api/representatives/:id\nDelete single representative\nRequired\n\n\nDELETE\n/api/representatives/cache\nClear entire cache\nRequired"},{"location":"v2/frontend/pages/admin/representatives-page/#load-representatives-paginated-with-filters","title":"Load Representatives (Paginated with Filters)","text":"

Request:

\n
const params: Record<string, unknown> = {\n  page: 1,\n  limit: 10,\n  search: 'John Smith',          // Optional: search query\n  postalCode: 'K1A 0A9',          // Optional: postal code filter\n  level: 'Federal',               // Optional: government level filter\n};\n\nconst { data } = await api.get<{\n  data: Representative[];\n  pagination: { total: number; page: number; limit: number };\n}>('/representatives', { params });\n
\n

Query Parameters:\n- page (number, required): Page number (1-indexed)\n- limit (number, required): Items per page (10, 25, 50, or 100)\n- search (string, optional): Search query (matches name, office, party, district)\n- postalCode (string, optional): Filter by postal code\n- level (string, optional): Filter by government level (Federal, Provincial, Municipal)

\n

Response (200 OK):

\n
{\n  \"data\": [\n    {\n      \"id\": \"rep_abc123\",\n      \"name\": \"John Smith\",\n      \"officeTitle\": \"Member of Parliament\",\n      \"level\": \"Federal\",\n      \"politicalParty\": \"Liberal Party of Canada\",\n      \"districtName\": \"Ottawa Centre\",\n      \"email\": \"john.smith@parl.gc.ca\",\n      \"phone\": \"+1 613-555-0100\",\n      \"fax\": \"+1 613-555-0101\",\n      \"photoUrl\": \"https://represent.opennorth.ca/photos/john-smith.jpg\",\n      \"personalUrl\": \"https://johnsmith.ca\",\n      \"officeAddress\": \"Justice Building, 284 Wellington Street, Room 432, Ottawa, ON K1A 0A6\",\n      \"mailingAddress\": \"House of Commons, Ottawa, ON K1A 0A6\",\n      \"socialMedia\": {\n        \"twitter\": \"https://twitter.com/johnsmith\",\n        \"facebook\": \"https://facebook.com/johnsmithmp\"\n      },\n      \"otherData\": {\n        \"first_name\": \"John\",\n        \"last_name\": \"Smith\",\n        \"elected_office\": \"MP\"\n      },\n      \"postalCode\": \"K1A 0A9\",\n      \"createdAt\": \"2026-01-15T10:30:00.000Z\",\n      \"updatedAt\": \"2026-01-15T10:30:00.000Z\"\n    },\n    {\n      \"id\": \"rep_def456\",\n      \"name\": \"Jane Doe\",\n      \"officeTitle\": \"Member of Provincial Parliament\",\n      \"level\": \"Provincial\",\n      \"politicalParty\": \"Progressive Conservative Party of Ontario\",\n      \"districtName\": \"Ottawa West\u2014Nepean\",\n      \"email\": \"jane.doe@ola.org\",\n      \"phone\": null,\n      \"fax\": null,\n      \"photoUrl\": null,\n      \"personalUrl\": null,\n      \"officeAddress\": null,\n      \"mailingAddress\": null,\n      \"socialMedia\": null,\n      \"otherData\": {},\n      \"postalCode\": \"K1A 0A9\",\n      \"createdAt\": \"2026-01-15T10:35:00.000Z\",\n      \"updatedAt\": \"2026-01-15T10:35:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 10,\n    \"total\": 245\n  }\n}\n
\n

Response Fields:

\n

Core fields (always present):\n- id (string): Unique representative identifier (prefixed with \"rep_\")\n- name (string): Full name\n- level (string): Government level (Federal, Provincial, or Municipal)\n- postalCode (string): Associated postal code\n- createdAt (ISO 8601): Cache entry creation timestamp\n- updatedAt (ISO 8601): Cache entry last update timestamp

\n

Optional fields (may be null):\n- officeTitle (string | null): Job title\n- politicalParty (string | null): Political party affiliation\n- districtName (string | null): Electoral district name\n- email (string | null): Contact email\n- phone (string | null): Contact phone\n- fax (string | null): Fax number\n- photoUrl (string | null): Portrait image URL\n- personalUrl (string | null): Personal/campaign website\n- officeAddress (string | null): Physical office location\n- mailingAddress (string | null): Mailing address\n- socialMedia (object | null): Social media links (twitter, facebook)\n- otherData (object): Additional custom fields from Represent API

"},{"location":"v2/frontend/pages/admin/representatives-page/#load-cache-statistics","title":"Load Cache Statistics","text":"

Request:

\n
const { data } = await api.get<CacheStats>('/representatives/stats');\n
\n

Response (200 OK):

\n
{\n  \"totalRepresentatives\": 245,\n  \"uniquePostalCodes\": 89,\n  \"avgRepsPerPostal\": 2.8,\n  \"breakdown\": {\n    \"Federal\": 89,\n    \"Provincial\": 89,\n    \"Municipal\": 67\n  }\n}\n
\n

Response Fields:\n- totalRepresentatives (number): Total cached representatives across all postal codes\n- uniquePostalCodes (number): Number of distinct postal codes in cache\n- avgRepsPerPostal (number): Average representatives per postal code (decimal)\n- breakdown (object): Count by government level (Federal, Provincial, Municipal)

\n

Statistics Calculation:

\n
// Backend calculation (api/src/modules/influence/representatives/representatives.service.ts)\nconst totalRepresentatives = await prisma.representative.count();\nconst uniquePostalCodes = await prisma.representative.findMany({\n  select: { postalCode: true },\n  distinct: ['postalCode'],\n});\nconst avgRepsPerPostal = uniquePostalCodes.length > 0\n  ? totalRepresentatives / uniquePostalCodes.length\n  : 0;\n\nconst breakdown = await prisma.representative.groupBy({\n  by: ['level'],\n  _count: { id: true },\n});\n
"},{"location":"v2/frontend/pages/admin/representatives-page/#lookup-representatives-by-postal-code","title":"Lookup Representatives by Postal Code","text":"

Request:

\n
const postalCode = 'K1A 0A9';\nconst { data } = await api.post<{\n  message: string;\n  count: number;\n  representatives: Representative[];\n}>(`/representatives/lookup/${postalCode}`);\n
\n

URL Parameter:\n- postalCode (string): Canadian postal code (format: \"A1A 1A1\" or \"A1A1A1\")

\n

Response (200 OK) - New Representatives Found:

\n
{\n  \"message\": \"Found 3 representatives for K1A 0A9 and cached them\",\n  \"count\": 3,\n  \"representatives\": [\n    {\n      \"id\": \"rep_abc123\",\n      \"name\": \"John Smith\",\n      \"level\": \"Federal\",\n      ...\n    },\n    {\n      \"id\": \"rep_def456\",\n      \"name\": \"Jane Doe\",\n      \"level\": \"Provincial\",\n      ...\n    },\n    {\n      \"id\": \"rep_ghi789\",\n      \"name\": \"Bob Johnson\",\n      \"level\": \"Municipal\",\n      ...\n    }\n  ]\n}\n
\n

Response (200 OK) - Already Cached:

\n
{\n  \"message\": \"Representatives for K1A 0A9 are already cached\",\n  \"count\": 3,\n  \"representatives\": [\n    {\n      \"id\": \"rep_abc123\",\n      \"name\": \"John Smith\",\n      \"level\": \"Federal\",\n      ...\n    }\n  ]\n}\n
\n

Response (200 OK) - No Representatives Found:

\n
{\n  \"message\": \"No representatives found for K1A 0A9\",\n  \"count\": 0,\n  \"representatives\": []\n}\n
\n

Error Response (400 Bad Request) - Invalid Postal Code:

\n
{\n  \"error\": \"Validation Error\",\n  \"details\": [\n    {\n      \"field\": \"postalCode\",\n      \"message\": \"Invalid postal code format. Expected format: A1A 1A1\"\n    }\n  ]\n}\n
\n

Error Response (503 Service Unavailable) - Represent API Down:

\n
{\n  \"error\": \"External service unavailable\",\n  \"message\": \"Represent API is temporarily unavailable. Please try again later.\"\n}\n
\n

Backend Workflow:

\n
// 1. Check if postal code already cached\nconst existingReps = await prisma.representative.findMany({\n  where: { postalCode: normalizedPostalCode },\n});\n\nif (existingReps.length > 0) {\n  return { message: 'Already cached', count: existingReps.length, representatives: existingReps };\n}\n\n// 2. Fetch from Represent API\nconst representResponse = await axios.get(\n  `https://represent.opennorth.ca/postcodes/${normalizedPostalCode}/?sets=federal-electoral-districts,provincial-electoral-districts,municipal-wards`\n);\n\n// 3. Transform and save to database\nconst reps = representResponse.data.representatives_centroid.map((rep: any) => ({\n  name: rep.name,\n  officeTitle: rep.elected_office,\n  level: determineLevel(rep.representative_set_name),\n  politicalParty: rep.party_name,\n  districtName: rep.district_name,\n  email: rep.email,\n  phone: rep.phone,\n  photoUrl: rep.photo_url,\n  personalUrl: rep.personal_url,\n  officeAddress: rep.office_address,\n  socialMedia: rep.extra?.social_media || null,\n  otherData: rep.extra || {},\n  postalCode: normalizedPostalCode,\n}));\n\nawait prisma.representative.createMany({ data: reps });\n
"},{"location":"v2/frontend/pages/admin/representatives-page/#delete-representative","title":"Delete Representative","text":"

Request:

\n
const repId = 'rep_abc123';\nawait api.delete(`/representatives/${repId}`);\n
\n

URL Parameter:\n- id (string): Representative ID to delete

\n

Response (200 OK):

\n
{\n  \"message\": \"Representative deleted from cache\"\n}\n
\n

Error Response (404 Not Found):

\n
{\n  \"error\": \"Not Found\",\n  \"message\": \"Representative not found with ID: rep_abc123\"\n}\n
"},{"location":"v2/frontend/pages/admin/representatives-page/#clear-entire-cache","title":"Clear Entire Cache","text":"

Request:

\n
const { data } = await api.delete<{ message: string; count: number }>('/representatives/cache');\n
\n

Response (200 OK):

\n
{\n  \"message\": \"Cache cleared successfully\",\n  \"count\": 245\n}\n
\n

Response Fields:\n- message (string): Confirmation message\n- count (number): Number of representatives deleted

\n

Backend Implementation:

\n
const count = await prisma.representative.count();\nawait prisma.representative.deleteMany({});\nreturn { message: 'Cache cleared successfully', count };\n
"},{"location":"v2/frontend/pages/admin/representatives-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#complete-representative-lookup-flow","title":"Complete Representative Lookup Flow","text":"
const handleLookup = async (values: { postalCode: string }) => {\n  setLookupLoading(true);\n  try {\n    const normalizedPostalCode = values.postalCode.toUpperCase().replace(/\\s+/g, ' ');\n\n    const { data } = await api.post<{\n      message: string;\n      count: number;\n      representatives: Representative[];\n    }>(`/representatives/lookup/${encodeURIComponent(normalizedPostalCode)}`);\n\n    if (data.count === 0) {\n      message.warning(data.message);\n    } else if (data.representatives.length > 0 && data.message.includes('already cached')) {\n      message.info(data.message);\n    } else {\n      message.success(data.message);\n    }\n\n    // Refresh table and stats\n    await Promise.all([loadRepresentatives(), loadStats()]);\n\n    setLookupModalOpen(false);\n    lookupForm.resetFields();\n  } catch (error) {\n    if (axios.isAxiosError(error) && error.response?.status === 503) {\n      message.error('Represent API is temporarily unavailable. Please try again later.');\n    } else if (axios.isAxiosError(error) && error.response?.status === 400) {\n      message.error('Invalid postal code format. Expected format: A1A 1A1');\n    } else {\n      message.error('Failed to lookup representatives');\n    }\n  } finally {\n    setLookupLoading(false);\n  }\n};\n
\n

Key Steps:\n1. Normalize postal code (uppercase, single space)\n2. URL-encode postal code for API request\n3. Handle three success scenarios (new, cached, not found)\n4. Show appropriate message type (success, info, warning)\n5. Refresh both table and statistics after successful lookup\n6. Close modal and reset form on success\n7. Handle specific error codes (503, 400)\n8. Always set loading state in finally block

"},{"location":"v2/frontend/pages/admin/representatives-page/#delete-with-confirmation","title":"Delete with Confirmation","text":"
const handleDeleteConfirm = (rep: Representative) => {\n  Modal.confirm({\n    title: 'Delete Representative',\n    content: `Are you sure you want to delete \"${rep.name}\" from the cache?`,\n    okText: 'Delete',\n    okType: 'danger',\n    cancelText: 'Cancel',\n    onOk: async () => {\n      try {\n        await api.delete(`/representatives/${rep.id}`);\n        message.success('Representative deleted from cache');\n\n        // Refresh table and stats\n        await Promise.all([loadRepresentatives(), loadStats()]);\n      } catch (error) {\n        message.error('Failed to delete representative');\n      }\n    },\n  });\n};\n
\n

Confirmation Pattern:\n- Uses Ant Design Modal.confirm static method (no state needed)\n- Shows representative name in confirmation text for clarity\n- Async onOk handler performs delete and refresh\n- Refreshes both table and stats to keep UI in sync\n- Error handling within onOk (doesn't prevent modal close)

"},{"location":"v2/frontend/pages/admin/representatives-page/#clear-all-cache-with-confirmation","title":"Clear All Cache with Confirmation","text":"
const handleClearCache = () => {\n  Modal.confirm({\n    title: 'Clear Cache',\n    content: stats\n      ? `Are you sure you want to clear the entire representative cache? This will delete all ${stats.totalRepresentatives} cached representatives.`\n      : 'Are you sure you want to clear the entire representative cache?',\n    okText: 'Clear Cache',\n    okType: 'danger',\n    cancelText: 'Cancel',\n    onOk: async () => {\n      try {\n        const { data } = await api.delete<{ message: string; count: number }>('/representatives/cache');\n        message.success(`Cache cleared successfully. Deleted ${data.count} representatives.`);\n\n        // Refresh table and stats\n        await Promise.all([loadRepresentatives(), loadStats()]);\n      } catch (error) {\n        message.error('Failed to clear cache');\n      }\n    },\n  });\n};\n
\n

Enhanced Confirmation:\n- Dynamically includes total count in confirmation message (if stats loaded)\n- Shows exact number of representatives that will be deleted\n- Success message includes deleted count for verification\n- Uses danger button styling to emphasize destructive action

"},{"location":"v2/frontend/pages/admin/representatives-page/#color-coded-government-level-tags","title":"Color-Coded Government Level Tags","text":"
const getLevelColor = (level: string): string => {\n  const colorMap: Record<string, string> = {\n    Federal: 'red',\n    Provincial: 'blue',\n    Municipal: 'green',\n  };\n  return colorMap[level] || 'default';\n};\n\n// Usage in table column\n{\n  title: 'Level',\n  dataIndex: 'level',\n  key: 'level',\n  width: 120,\n  render: (level: string) => (\n    <Tag color={getLevelColor(level)}>{level}</Tag>\n  ),\n  sorter: (a, b) => a.level.localeCompare(b.level),\n}\n
\n

Color Mapping:\n- Federal: Red (highest level of government)\n- Provincial: Blue (middle level)\n- Municipal: Green (local level)\n- Default: Gray (unknown/other levels)

"},{"location":"v2/frontend/pages/admin/representatives-page/#debounced-filter-implementation","title":"Debounced Filter Implementation","text":"
// Component state\nconst searchTimerRef = useRef<NodeJS.Timeout | null>(null);\nconst postalTimerRef = useRef<NodeJS.Timeout | null>(null);\n\n// Search input handler\nconst handleSearch = (value: string) => {\n  if (searchTimerRef.current) {\n    clearTimeout(searchTimerRef.current);\n  }\n\n  searchTimerRef.current = setTimeout(() => {\n    setSearch(value);\n    setPagination((prev) => ({ ...prev, current: 1 }));\n  }, 300);\n};\n\n// Postal code filter handler\nconst handlePostalCodeFilterChange = (value: string) => {\n  if (postalTimerRef.current) {\n    clearTimeout(postalTimerRef.current);\n  }\n\n  postalTimerRef.current = setTimeout(() => {\n    setPostalCodeFilter(value);\n    setPagination((prev) => ({ ...prev, current: 1 }));\n  }, 300);\n};\n\n// Cleanup on unmount\nuseEffect(() => {\n  return () => {\n    if (searchTimerRef.current) clearTimeout(searchTimerRef.current);\n    if (postalTimerRef.current) clearTimeout(postalTimerRef.current);\n  };\n}, []);\n\n// Input components\n<Input.Search\n  placeholder=\"Search representatives...\"\n  allowClear\n  onChange={(e) => handleSearch(e.target.value)}\n  style={{ width: '100%' }}\n/>\n\n<Input\n  placeholder=\"Filter by postal code...\"\n  allowClear\n  onChange={(e) => handlePostalCodeFilterChange(e.target.value)}\n  style={{ width: '100%' }}\n/>\n
\n

Two Independent Debounce Timers:\n- Separate refs: searchTimerRef and postalTimerRef allow independent debouncing\n- 300ms delay: Balances responsiveness and performance\n- Reset pagination: Both filters reset to page 1 when changed\n- Cleanup effect: Clears timers on unmount to prevent memory leaks\n- allowClear: Ant Design Input feature adds X icon to clear field

"},{"location":"v2/frontend/pages/admin/representatives-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#efficient-pagination","title":"Efficient Pagination","text":"

The page uses server-side pagination to handle large cache datasets efficiently:

\n
const { data } = await api.get('/representatives', {\n  params: {\n    page: pagination.current,\n    limit: pagination.pageSize,\n    search,\n    postalCodeFilter,\n    levelFilter,\n  },\n});\n
\n

Benefits:\n- Reduced payload: Only fetches current page (10-100 items) instead of all 245+\n- Fast rendering: Table renders 10-100 rows instead of potentially thousands\n- Scalable: Works efficiently with cache sizes from 10 to 10,000+ representatives\n- Combined filtering: Backend applies filters before pagination, returning only relevant results

"},{"location":"v2/frontend/pages/admin/representatives-page/#debounced-search-300ms","title":"Debounced Search (300ms)","text":"

Prevents API spam during typing:

\n
searchTimerRef.current = setTimeout(() => {\n  setSearch(value);\n}, 300);\n
\n

Performance Impact:\n- Without debounce: Typing \"John Smith\" (10 characters) = 10 API calls\n- With 300ms debounce: Typing \"John Smith\" = 1 API call (after 300ms pause)\n- Network savings: 90% reduction in API requests for typical typing speed\n- Backend load: Reduces database queries and Represent API calls

"},{"location":"v2/frontend/pages/admin/representatives-page/#usecallback-for-fetch-functions","title":"useCallback for Fetch Functions","text":"

Prevents unnecessary re-renders:

\n
const loadRepresentatives = useCallback(async () => {\n  // ... fetch logic\n}, [pagination.current, pagination.pageSize, search, postalCodeFilter, levelFilter]);\n
\n

Why This Matters:\n- Without useCallback: Function reference changes every render, triggering useEffect infinitely\n- With useCallback: Function reference only changes when dependencies change\n- Result: useEffect runs only when filters/pagination actually change, not on every render

"},{"location":"v2/frontend/pages/admin/representatives-page/#statistics-caching","title":"Statistics Caching","text":"

Statistics are loaded separately and don't re-fetch on table filter changes:

\n
const loadStats = useCallback(async () => {\n  const { data } = await api.get<CacheStats>('/representatives/stats');\n  setStats(data);\n}, []);\n\nuseEffect(() => {\n  loadStats();\n}, [loadStats]); // Only runs on mount\n
\n

Benefits:\n- Independent updates: Stats only refresh after lookup/delete operations, not on search/filter\n- Reduced API calls: Stats don't need to be recalculated for every table filter\n- Better UX: Statistics cards remain stable while user searches/filters table

"},{"location":"v2/frontend/pages/admin/representatives-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#mobile-layout","title":"Mobile Layout","text":"

The page adapts gracefully to mobile viewports:

\n

Statistics Cards:\n

<Row gutter={[16, 16]}>\n  <Col xs={24} sm={8}>  {/* Full width mobile, 1/3 width desktop */}\n    <Card size=\"small\">\n      <Statistic title=\"Cached Representatives\" value={stats.totalRepresentatives} />\n    </Card>\n  </Col>\n  {/* Repeat for other cards */}\n</Row>\n

\n

Filter Inputs:\n

<Row gutter={[16, 16]}>\n  <Col xs={24} md={12}>  {/* Full width mobile, half width desktop */}\n    <Input.Search placeholder=\"Search representatives...\" />\n  </Col>\n  <Col xs={24} md={12}>  {/* Full width mobile, half width desktop */}\n    <Input placeholder=\"Filter by postal code...\" />\n  </Col>\n</Row>\n

\n

Responsive Grid Breakpoints:\n- xs (mobile, <576px): Stacked layout, full-width cards and inputs\n- sm (tablet, \u2265576px): 3-column statistics cards\n- md (desktop, \u2265768px): Side-by-side filter inputs\n- lg+ (large desktop, \u2265992px): Full table width with all columns visible

"},{"location":"v2/frontend/pages/admin/representatives-page/#table-column-responsiveness","title":"Table Column Responsiveness","text":"

Columns use responsive prop to hide on mobile:

\n
{\n  title: 'Email',\n  dataIndex: 'email',\n  key: 'email',\n  responsive: ['md'],  // Hidden on mobile (xs, sm)\n  render: (email: string | null) => (\n    email ? <a href={`mailto:${email}`}>{email}</a> : '\u2014'\n  ),\n}\n
\n

Mobile Table (xs, sm):\n- Name (visible)\n- Level (visible with color tags)\n- Actions (visible)

\n

Desktop Table (md+):\n- Name + Office + Level + Party + District + Email + Actions (all visible)

"},{"location":"v2/frontend/pages/admin/representatives-page/#drawer-width","title":"Drawer Width","text":"

Detail drawer adapts to screen size:

\n
<Drawer\n  width={600}  // 600px on desktop\n  // On mobile (xs), automatically becomes full-width\n  placement=\"right\"\n  open={detailDrawerOpen}\n  onClose={() => setDetailDrawerOpen(false)}\n>\n
\n

Behavior:\n- Desktop (\u2265768px): 600px slide-in panel from right\n- Mobile (<768px): Full-width slide-in panel (automatically handled by Ant Design)

"},{"location":"v2/frontend/pages/admin/representatives-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#keyboard-navigation","title":"Keyboard Navigation","text":"

All interactive elements are keyboard-accessible:

\n

Table Navigation:\n- Tab: Move between action buttons (View, Delete)\n- Enter/Space: Activate focused button\n- Arrow Keys: Navigate table rows (Ant Design built-in)

\n

Form Fields:\n- Tab: Move between search input, postal code filter, level dropdown\n- Escape: Clear input fields (when using allowClear)\n- Enter: Submit lookup form

\n

Modal/Drawer:\n- Escape: Close modal or drawer\n- Tab: Cycle through focusable elements inside modal/drawer\n- Enter: Confirm action (in confirmation modals)

"},{"location":"v2/frontend/pages/admin/representatives-page/#screen-reader-support","title":"Screen Reader Support","text":"

The page provides semantic HTML and ARIA labels:

\n

Statistics Cards:\n

<Statistic\n  title=\"Cached Representatives\"  // Read by screen readers\n  value={stats.totalRepresentatives}\n  prefix={<TeamOutlined />}\n/>\n

\n

Action Buttons:\n

<Button\n  icon={<EyeOutlined />}\n  onClick={() => handleViewDetails(record)}\n  aria-label={`View details for ${record.name}`}\n>\n  View\n</Button>\n\n<Button\n  icon={<DeleteOutlined />}\n  onClick={() => handleDeleteConfirm(record)}\n  aria-label={`Delete ${record.name} from cache`}\n  danger\n>\n  Delete\n</Button>\n

\n

Table Sorting:\n

{\n  title: 'Name',\n  sorter: (a, b) => a.name.localeCompare(b.name),\n  // Ant Design automatically adds aria-sort=\"ascending|descending|none\"\n}\n

"},{"location":"v2/frontend/pages/admin/representatives-page/#color-contrast","title":"Color Contrast","text":"

All color-coded elements meet WCAG AA standards:

\n

Government Level Tags:\n- Federal (red): #f5222d on white background = 4.5:1 contrast ratio\n- Provincial (blue): #1890ff on white background = 4.5:1 contrast ratio\n- Municipal (green): #52c41a on white background = 4.5:1 contrast ratio

\n

Text Colors:\n- Primary text: rgba(0, 0, 0, 0.85) = 13.6:1 contrast ratio\n- Secondary text: rgba(0, 0, 0, 0.45) = 7.0:1 contrast ratio (used for \"\u2014\" null values)

"},{"location":"v2/frontend/pages/admin/representatives-page/#focus-indicators","title":"Focus Indicators","text":"

All interactive elements have visible focus states:

\n

Buttons:\n

.ant-btn:focus {\n  outline: 2px solid #1890ff;\n  outline-offset: 2px;\n}\n

\n

Input Fields:\n

.ant-input:focus {\n  border-color: #40a9ff;\n  box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);\n}\n

"},{"location":"v2/frontend/pages/admin/representatives-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#representatives-not-loading","title":"Representatives Not Loading","text":"

Problem: Page shows empty state or loading spinner indefinitely.

\n

Diagnosis:

\n

Check browser console for errors:

\n
// Console error\nGET https://api.cmlite.org/representatives 401 Unauthorized\n
\n

Possible Causes:

\n
    \n
  1. Not authenticated:
  2. \n
  3. Check if JWT access token is expired
  4. \n
  5. \n

    Check if user has required role (SUPER_ADMIN or INFLUENCE_ADMIN)

    \n
  6. \n
  7. \n

    Backend API down:

    \n
  8. \n
  9. Verify API container is running: docker compose ps api
  10. \n
  11. \n

    Check API logs: docker compose logs api

    \n
  12. \n
  13. \n

    Database connection issue:

    \n
  14. \n
  15. Verify PostgreSQL is running: docker compose ps v2-postgres
  16. \n
  17. Check database connection: docker compose exec api npx prisma db push
  18. \n
\n

Solution:

\n
    \n
  1. Refresh page to trigger token refresh
  2. \n
  3. Log out and log back in to get new token
  4. \n
  5. Verify backend services are running: docker compose up -d api v2-postgres
  6. \n
  7. Check API logs for specific error: docker compose logs -f api | grep representatives
  8. \n
"},{"location":"v2/frontend/pages/admin/representatives-page/#postal-code-lookup-fails","title":"Postal Code Lookup Fails","text":"

Problem: Click \"Lookup New Postal Code\", enter postal code, get error: \"Failed to lookup representatives\".

\n

Diagnosis:

\n

Check network tab in browser DevTools:

\n
// Response from POST /representatives/lookup/K1A0A9\n{\n  \"error\": \"External service unavailable\",\n  \"message\": \"Represent API is temporarily unavailable. Please try again later.\"\n}\n
\n

Possible Causes:

\n
    \n
  1. Represent API down:
  2. \n
  3. represent.opennorth.ca is temporarily unavailable (503)
  4. \n
  5. \n

    Network firewall blocking external API requests

    \n
  6. \n
  7. \n

    Invalid postal code format:

    \n
  8. \n
  9. Missing space in postal code (should be \"K1A 0A9\", not \"K1A0A9\")
  10. \n
  11. \n

    Non-Canadian postal code entered

    \n
  12. \n
  13. \n

    Rate limit exceeded:

    \n
  14. \n
  15. Too many requests to Represent API from same IP
  16. \n
  17. Represent API rate limit: 60 requests/minute/IP
  18. \n
\n

Solution:

\n
    \n
  1. For Represent API downtime:
  2. \n
  3. Wait 5-10 minutes and retry
  4. \n
  5. Check Represent API status: https://represent.opennorth.ca/postcodes/K1A0A9/
  6. \n
  7. \n

    Use cached representatives if available (search for existing postal code)

    \n
  8. \n
  9. \n

    For invalid postal code:

    \n
  10. \n
  11. Ensure format is \"A1A 1A1\" with space (e.g., \"K1A 0A9\")
  12. \n
  13. Use Canadian postal codes only (Represent API only covers Canada)
  14. \n
  15. \n

    Try a known-valid postal code: \"K1A 0A9\" (Ottawa, Parliament Hill)

    \n
  16. \n
  17. \n

    For rate limits:

    \n
  18. \n
  19. Wait 1 minute before retrying
  20. \n
  21. Reduce lookup frequency (don't spam the button)
  22. \n
  23. Check existing cache first (use search/filter)
  24. \n
"},{"location":"v2/frontend/pages/admin/representatives-page/#delete-button-not-working","title":"Delete Button Not Working","text":"

Problem: Click \"Delete\" button, confirmation modal appears, click \"Delete\" again, but representative remains in table.

\n

Diagnosis:

\n

Check network tab:

\n
// Response from DELETE /representatives/rep_abc123\n{\n  \"error\": \"Forbidden\",\n  \"message\": \"Insufficient permissions. Required role: SUPER_ADMIN or INFLUENCE_ADMIN\"\n}\n
\n

Possible Causes:

\n
    \n
  1. Insufficient permissions:
  2. \n
  3. User role is USER (not INFLUENCE_ADMIN or SUPER_ADMIN)
  4. \n
  5. \n

    JWT token has expired and refresh failed

    \n
  6. \n
  7. \n

    Representative already deleted:

    \n
  8. \n
  9. Another admin deleted the representative concurrently
  10. \n
  11. \n

    Table hasn't refreshed to reflect deletion

    \n
  12. \n
  13. \n

    Database constraint violation:

    \n
  14. \n
  15. Representative is referenced by campaign emails (foreign key constraint)
  16. \n
  17. Cannot delete due to active references
  18. \n
\n

Solution:

\n
    \n
  1. For permission issues:
  2. \n
  3. Contact system administrator to grant INFLUENCE_ADMIN role
  4. \n
  5. Log out and log back in to refresh permissions
  6. \n
  7. \n

    Check user role in profile dropdown (top-right corner)

    \n
  8. \n
  9. \n

    For concurrent deletion:

    \n
  10. \n
  11. Refresh page to see current cache state
  12. \n
  13. \n

    If representative is gone, deletion succeeded (UI just didn't update)

    \n
  14. \n
  15. \n

    For constraint violations:

    \n
  16. \n
  17. Delete dependent records first (campaign emails referencing this representative)
  18. \n
  19. Or use \"Clear All Cache\" button to delete everything at once (cascading delete)
  20. \n
"},{"location":"v2/frontend/pages/admin/representatives-page/#search-not-working","title":"Search Not Working","text":"

Problem: Type search query in \"Search Representatives\" field, but table doesn't filter.

\n

Diagnosis:

\n

Check if debounce timer is working:

\n
// Wait 300ms after typing\n// If table still doesn't update, check console for errors\n
\n

Possible Causes:

\n
    \n
  1. Typing too fast:
  2. \n
  3. Debounce timer resets on every keystroke
  4. \n
  5. \n

    Must wait 300ms after last keystroke

    \n
  6. \n
  7. \n

    Case sensitivity:

    \n
  8. \n
  9. Search is case-insensitive on backend, but special characters may cause issues
  10. \n
  11. \n

    Accent characters (\u00e9, \u00e0, \u00f1) may not match correctly

    \n
  12. \n
  13. \n

    Search scope confusion:

    \n
  14. \n
  15. Search only matches: name, officeTitle, politicalParty, districtName
  16. \n
  17. Does NOT search: email, phone, addresses, otherData
  18. \n
\n

Solution:

\n
    \n
  1. For typing speed:
  2. \n
  3. Pause typing for 300ms (about half a second)
  4. \n
  5. \n

    Watch for table loading spinner to confirm search triggered

    \n
  6. \n
  7. \n

    For special characters:

    \n
  8. \n
  9. Remove accents (e.g., search \"Montreal\" instead of \"Montr\u00e9al\")
  10. \n
  11. \n

    Use partial matches (e.g., \"Smith\" instead of \"O'Smith\")

    \n
  12. \n
  13. \n

    For search scope:

    \n
  14. \n
  15. Search by name: \"John Smith\"
  16. \n
  17. Search by party: \"Liberal\"
  18. \n
  19. Search by district: \"Ottawa Centre\"
  20. \n
  21. Use postal code filter for location-based filtering (separate input field)
  22. \n
"},{"location":"v2/frontend/pages/admin/representatives-page/#statistics-not-updating","title":"Statistics Not Updating","text":"

Problem: Add new representatives via lookup, but statistics cards still show old counts.

\n

Diagnosis:

\n

Check if loadStats() is called after lookup:

\n
// Should see this in network tab after successful lookup\nGET /representatives/lookup/K1A0A9 \u2192 200 OK\nGET /representatives/stats \u2192 200 OK  // \u2190 Should appear here\n
\n

Possible Causes:

\n
    \n
  1. Frontend bug:
  2. \n
  3. loadStats() not called after successful lookup
  4. \n
  5. \n

    Promise.all([loadRepresentatives(), loadStats()]) failed silently

    \n
  6. \n
  7. \n

    Backend calculation error:

    \n
  8. \n
  9. Statistics endpoint returning cached/stale data
  10. \n
  11. \n

    Database aggregation query not reflecting new records

    \n
  12. \n
  13. \n

    Cache invalidation:

    \n
  14. \n
  15. Backend caching statistics for performance
  16. \n
  17. Cache not invalidated after lookup/delete operations
  18. \n
\n

Solution:

\n
    \n
  1. Manual refresh:
  2. \n
  3. Refresh entire page (F5 or Ctrl+R)
  4. \n
  5. \n

    Statistics should update to reflect current cache state

    \n
  6. \n
  7. \n

    Check backend logs:

    \n
  8. \n
  9. Look for statistics calculation errors: docker compose logs api | grep stats
  10. \n
  11. \n

    Verify database connection during stats calculation

    \n
  12. \n
  13. \n

    Developer fix (if bug):

    \n
  14. \n
  15. Ensure loadStats() is called after lookup/delete:\n
    await Promise.all([loadRepresentatives(), loadStats()]);\n
  16. \n
  17. Remove backend statistics caching (if implemented)
  18. \n
"},{"location":"v2/frontend/pages/admin/representatives-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/responses-page/","title":"ResponsesPage","text":""},{"location":"v2/frontend/pages/admin/responses-page/#overview","title":"Overview","text":"

The ResponsesPage provides moderation and management for public campaign responses submitted through the response wall feature. Administrators can review user submissions, approve or reject responses for public display, resend verification emails, and view detailed response information. Features include advanced filtering by status and campaign, search functionality, and clickable rows for detailed views.

Route: /app/influence/responses Component: admin/src/pages/ResponsesPage.tsx (400 lines) Auth Required: Yes (SUPER_ADMIN, INFLUENCE_ADMIN roles) Layout: AppLayout

"},{"location":"v2/frontend/pages/admin/responses-page/#screenshot","title":"Screenshot","text":"

[Screenshot: Responses page with search bar + status dropdown + campaign dropdown at top. Main table shows columns: Representative (name), Level (government level tag), Type (EMAIL/PHONE tag), Campaign (title), Status (PENDING/APPROVED/REJECTED colored tag), Verified (checkmark icon if true), Upvotes (count), Submitted (date), Actions (Approve, Reject, Verify, Delete buttons). Rows clickable to open detail drawer. Page header has \"Refresh\" button.]

"},{"location":"v2/frontend/pages/admin/responses-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/responses-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/responses-page/#viewing-responses-list","title":"Viewing Responses List","text":"
  1. Navigate to /app/influence/responses
  2. Page loads first 20 responses (paginated)
  3. View response details:
  4. Representative name
  5. Government level tag (colored)
  6. Response type (EMAIL/PHONE)
  7. Campaign title
  8. Status tag (PENDING: yellow, APPROVED: green, REJECTED: red)
  9. Verified checkmark icon (if email verified)
  10. Upvote count
  11. Submitted date
  12. Action buttons
  13. Click any row to open detail drawer
"},{"location":"v2/frontend/pages/admin/responses-page/#filtering-responses","title":"Filtering Responses","text":"
  1. Status filter (dropdown):
  2. Select PENDING, APPROVED, or REJECTED
  3. Clear to show all statuses
  4. Campaign filter (dropdown with search):
  5. Select campaign from list (up to 100 campaigns)
  6. Type to search campaign titles
  7. Clear to show all campaigns
  8. Search bar:
  9. Type keywords to search response text
  10. 400ms debounce (waits for typing pause)
  11. Search resets pagination to page 1
  12. Filters combine (AND logic): status + campaign + search
"},{"location":"v2/frontend/pages/admin/responses-page/#approving-a-response","title":"Approving a Response","text":"
  1. Locate PENDING or REJECTED response in table
  2. Click \"Approve\" button (green, CheckCircleOutlined icon)
  3. Button shows loading spinner
  4. Success message: \"Response approved\"
  5. Table refreshes with updated status
  6. Effect:
  7. Response status changes to APPROVED
  8. Response now visible on public response wall
  9. User receives confirmation email (if email verified)
  10. Approve button hidden if response already APPROVED
"},{"location":"v2/frontend/pages/admin/responses-page/#rejecting-a-response","title":"Rejecting a Response","text":"
  1. Locate PENDING or APPROVED response in table
  2. Click \"Reject\" button (red, CloseCircleOutlined icon)
  3. Button shows loading spinner
  4. Success message: \"Response rejected\"
  5. Table refreshes with updated status
  6. Effect:
  7. Response status changes to REJECTED
  8. Response hidden from public response wall
  9. User does NOT receive notification (silent rejection)
  10. Reject button hidden if response already REJECTED
"},{"location":"v2/frontend/pages/admin/responses-page/#resending-verification-email","title":"Resending Verification Email","text":"
  1. Locate response with representativeEmail (not null)
  2. Click \"Verify\" button (MailOutlined icon)
  3. Button shows loading spinner
  4. Success message: \"Verification email sent\"
  5. Email sent to user with verification link
  6. User clicks link \u2192 isVerified set to true
  7. Verified responses show SafetyCertificateOutlined icon

Verification flow: 1. User submits response on public page 2. System sends verification email to submittedByEmail 3. User clicks verification link in email 4. Backend marks response as verified (isVerified: true) 5. Verified responses show checkmark icon in table

"},{"location":"v2/frontend/pages/admin/responses-page/#deleting-a-response","title":"Deleting a Response","text":"
  1. Locate response in table
  2. Click Delete icon button (DeleteOutlined, red)
  3. Popconfirm: \"Delete this response?\"
  4. Click \"OK\" to confirm
  5. Button shows loading spinner
  6. Success message: \"Response deleted\"
  7. Table refreshes (response disappears)
  8. Cascade behavior: Response record permanently deleted from database

Warning: Deletion is permanent. No soft delete. Upvotes also deleted (cascade).

"},{"location":"v2/frontend/pages/admin/responses-page/#viewing-response-details","title":"Viewing Response Details","text":"
  1. Click any row in table
  2. Detail drawer opens on right side (520px width)
  3. Drawer content (Descriptions component with bordered layout):
  4. Representative: Name + Title (if present)
  5. Level: Government level tag (colored)
  6. Type: Response type tag (EMAIL/PHONE)
  7. Campaign: Campaign title
  8. Status: Status tag (colored)
  9. Verified: Yes/No + verified by + verified date (if verified)
  10. Upvotes: Count
  11. Response Text: Full response text (scrollable, max 300px height, pre-wrap)
  12. User Comment: Additional comment (if present)
  13. Submitted By: Name + Email OR \"Anonymous\" (if anonymous)
  14. Submitted: Full timestamp (MMM D, YYYY h:mm A)
  15. Click \"X\" or outside drawer to close
"},{"location":"v2/frontend/pages/admin/responses-page/#refreshing-data","title":"Refreshing Data","text":"
  1. Click \"Refresh\" button in page header
  2. Table reloads with current filters applied
  3. Fetches latest responses from API
  4. Useful for checking new submissions without full page reload
"},{"location":"v2/frontend/pages/admin/responses-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/responses-page/#ant-design-components-used","title":"Ant Design Components Used","text":""},{"location":"v2/frontend/pages/admin/responses-page/#table-columns","title":"Table Columns","text":"
const columns: ColumnsType<RepresentativeResponse> = [\n  {\n    title: 'Representative',\n    dataIndex: 'representativeName',\n    ellipsis: true,\n  },\n  {\n    title: 'Level',\n    dataIndex: 'representativeLevel',\n    width: 120,\n    render: (level) => (\n      <Tag color={GOVERNMENT_LEVEL_COLORS[level]}>\n        {GOVERNMENT_LEVEL_LABELS[level]}\n      </Tag>\n    ),\n  },\n  {\n    title: 'Type',\n    dataIndex: 'responseType',\n    width: 110,\n    render: (type) => <Tag>{RESPONSE_TYPE_LABELS[type]}</Tag>,\n  },\n  {\n    title: 'Campaign',\n    width: 160,\n    ellipsis: true,\n    render: (_, record) => record.campaign?.title || '\u2014',\n  },\n  {\n    title: 'Status',\n    dataIndex: 'status',\n    width: 100,\n    render: (status) => (\n      <Tag color={RESPONSE_STATUS_COLORS[status]}>{status}</Tag>\n    ),\n  },\n  {\n    title: 'Verified',\n    width: 80,\n    align: 'center',\n    responsive: ['md'],\n    render: (_, record) =>\n      record.isVerified ? (\n        <SafetyCertificateOutlined style={{ color: '#16a34a', fontSize: 16 }} />\n      ) : null,\n  },\n  {\n    title: 'Upvotes',\n    dataIndex: 'upvoteCount',\n    width: 80,\n    align: 'center',\n    responsive: ['md'],\n  },\n  {\n    title: 'Submitted',\n    dataIndex: 'createdAt',\n    width: 120,\n    responsive: ['sm'],\n    render: (date) => dayjs(date).format('MMM D, YYYY'),\n  },\n  {\n    title: 'Actions',\n    width: 200,\n    render: (_, record) => (\n      <Space size=\"small\" wrap>\n        {record.status !== 'APPROVED' && (\n          <Button size=\"small\" type=\"link\" icon={<CheckCircleOutlined />} style={{ color: '#16a34a' }} loading={actionLoading === record.id} onClick={() => handleApprove(record.id)}>\n            Approve\n          </Button>\n        )}\n        {record.status !== 'REJECTED' && (\n          <Button size=\"small\" type=\"link\" icon={<CloseCircleOutlined />} danger loading={actionLoading === record.id} onClick={() => handleReject(record.id)}>\n            Reject\n          </Button>\n        )}\n        {record.representativeEmail && (\n          <Button size=\"small\" type=\"link\" icon={<MailOutlined />} loading={actionLoading === record.id} onClick={() => handleResendVerification(record.id)}>\n            Verify\n          </Button>\n        )}\n        <Popconfirm title=\"Delete this response?\" onConfirm={() => handleDelete(record.id)}>\n          <Button size=\"small\" type=\"link\" icon={<DeleteOutlined />} danger loading={actionLoading === record.id} />\n        </Popconfirm>\n      </Space>\n    ),\n  },\n];\n

Key patterns: - Conditional button rendering: Approve hidden if APPROVED, Reject hidden if REJECTED - Verify button only shown if representativeEmail exists - actionLoading state tracks which row's action is in progress - wrap on Space allows buttons to wrap on narrow screens

"},{"location":"v2/frontend/pages/admin/responses-page/#status-colors","title":"Status Colors","text":"
export const RESPONSE_STATUS_COLORS = {\n  PENDING: 'gold',      // Yellow (awaiting moderation)\n  APPROVED: 'green',    // Green (visible on public wall)\n  REJECTED: 'red',      // Red (hidden from public)\n};\n
"},{"location":"v2/frontend/pages/admin/responses-page/#government-level-colors","title":"Government Level Colors","text":"
export const GOVERNMENT_LEVEL_COLORS = {\n  FEDERAL: 'blue',\n  PROVINCIAL: 'purple',\n  MUNICIPAL: 'cyan',\n  SCHOOL_BOARD: 'magenta',\n};\n
"},{"location":"v2/frontend/pages/admin/responses-page/#response-type-labels","title":"Response Type Labels","text":"
export const RESPONSE_TYPE_LABELS = {\n  EMAIL: 'Email',      // Sent via SMTP to representative\n  PHONE: 'Phone',      // Called representative\n};\n
"},{"location":"v2/frontend/pages/admin/responses-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/responses-page/#zustand-stores-used","title":"Zustand Stores Used","text":"

None \u2014 Responses fetched from API on each interaction.

"},{"location":"v2/frontend/pages/admin/responses-page/#local-state","title":"Local State","text":"
const [responses, setResponses] = useState<RepresentativeResponse[]>([]);\nconst [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });\nconst [loading, setLoading] = useState(false);\nconst [actionLoading, setActionLoading] = useState<string | null>(null);  // Row ID with action in progress\nconst [statusFilter, setStatusFilter] = useState<ResponseStatus | undefined>();\nconst [campaignFilter, setCampaignFilter] = useState<string | undefined>();\nconst [search, setSearch] = useState('');\nconst [campaigns, setCampaigns] = useState<Campaign[]>([]);\nconst [detailResponse, setDetailResponse] = useState<RepresentativeResponse | null>(null);\nconst searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\n
"},{"location":"v2/frontend/pages/admin/responses-page/#debounced-search","title":"Debounced Search","text":"
const handleSearch = (value: string) => {\n  if (searchTimerRef.current) clearTimeout(searchTimerRef.current);\n  searchTimerRef.current = setTimeout(() => {\n    setSearch(value);\n  }, 400);\n};\n

Why 400ms? Slightly longer than other pages (300ms) \u2014 response text can be lengthy, giving users more time to type complete phrases.

"},{"location":"v2/frontend/pages/admin/responses-page/#per-row-action-loading","title":"Per-Row Action Loading","text":"
const [actionLoading, setActionLoading] = useState<string | null>(null);\n\nconst handleApprove = async (id: string) => {\n  setActionLoading(id);  // Mark this row as loading\n  try {\n    await api.patch(`/responses/${id}/status`, { status: 'APPROVED' });\n    message.success('Response approved');\n    fetchResponses(pagination.page);\n  } catch {\n    message.error('Failed to approve response');\n  } finally {\n    setActionLoading(null);  // Clear loading state\n  }\n};\n

Pattern: Only the clicked button shows loading spinner, not all buttons in table.

"},{"location":"v2/frontend/pages/admin/responses-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/responses-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose GET /api/responses List responses (paginated, filtered) PATCH /api/responses/:id/status Update response status (approve/reject) POST /api/responses/:id/resend-verification Resend verification email DELETE /api/responses/:id Delete response GET /api/campaigns List campaigns for filter dropdown"},{"location":"v2/frontend/pages/admin/responses-page/#list-responses","title":"List Responses","text":"

Request:

const { data } = await api.get<ResponsesListResponse>('/responses', {\n  params: {\n    page: 1,\n    limit: 20,\n    status: 'PENDING',           // Optional: filter by status\n    campaignId: 'cm-123',        // Optional: filter by campaign\n    search: 'climate change',    // Optional: search response text\n  },\n});\n

Response:

{\n  \"responses\": [\n    {\n      \"id\": \"resp-123\",\n      \"representativeName\": \"Hon. Jane Smith\",\n      \"representativeTitle\": \"MP for Ottawa Centre\",\n      \"representativeLevel\": \"FEDERAL\",\n      \"representativeEmail\": \"jane.smith@parl.gc.ca\",\n      \"responseType\": \"EMAIL\",\n      \"responseText\": \"I contacted my MP about climate action and urged support for renewable energy legislation.\",\n      \"userComment\": \"Looking forward to their response!\",\n      \"status\": \"PENDING\",\n      \"isVerified\": false,\n      \"verifiedBy\": null,\n      \"verifiedAt\": null,\n      \"upvoteCount\": 3,\n      \"isAnonymous\": false,\n      \"submittedByName\": \"John Doe\",\n      \"submittedByEmail\": \"john@example.com\",\n      \"campaignId\": \"cm-456\",\n      \"campaign\": {\n        \"id\": \"cm-456\",\n        \"title\": \"Contact Your MP About Climate Action\"\n      },\n      \"createdAt\": \"2026-02-10T14:30:00.000Z\",\n      \"updatedAt\": \"2026-02-10T14:30:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 47,\n    \"totalPages\": 3\n  }\n}\n

Key fields: - representativeEmail \u2014 Email address (may be null if not available from Represent API) - isVerified \u2014 Email address verified by user clicking link - verifiedBy / verifiedAt \u2014 Verification metadata - upvoteCount \u2014 Denormalized counter (updated on upvote/unvote) - isAnonymous \u2014 If true, hide submitter name/email on public wall

"},{"location":"v2/frontend/pages/admin/responses-page/#update-response-status","title":"Update Response Status","text":"

Request (Approve):

await api.patch(`/responses/${responseId}/status`, { status: 'APPROVED' });\n

Request (Reject):

await api.patch(`/responses/${responseId}/status`, { status: 'REJECTED' });\n

Response:

{\n  \"id\": \"resp-123\",\n  \"status\": \"APPROVED\",\n  \"updatedAt\": \"2026-02-11T10:15:00.000Z\"\n}\n
"},{"location":"v2/frontend/pages/admin/responses-page/#resend-verification-email","title":"Resend Verification Email","text":"

Request:

await api.post(`/responses/${responseId}/resend-verification`);\n

Response: 204 No Content

Email sent to: submittedByEmail with verification link

Verification link format:

https://app.cmlite.org/responses/verify?token={jwt_token}\n

Email template:

Subject: Verify Your Response Submission\n\nHi {submittedByName},\n\nPlease verify your email address by clicking the link below:\n\n{verification_link}\n\nThis ensures your response is authentic and can be displayed publicly.\n\nThank you for participating!\nChangemaker Lite\n
"},{"location":"v2/frontend/pages/admin/responses-page/#delete-response","title":"Delete Response","text":"

Request:

await api.delete(`/responses/${responseId}`);\n

Response: 204 No Content

Cascade behavior: - ResponseUpvote records deleted (Prisma cascade)

"},{"location":"v2/frontend/pages/admin/responses-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/responses-page/#conditional-action-buttons","title":"Conditional Action Buttons","text":"
<Space size=\"small\" wrap>\n  {/* Approve button: hidden if already APPROVED */}\n  {record.status !== 'APPROVED' && (\n    <Button\n      size=\"small\"\n      type=\"link\"\n      icon={<CheckCircleOutlined />}\n      style={{ color: '#16a34a' }}  // Green\n      loading={actionLoading === record.id}\n      onClick={() => handleApprove(record.id)}\n    >\n      Approve\n    </Button>\n  )}\n\n  {/* Reject button: hidden if already REJECTED */}\n  {record.status !== 'REJECTED' && (\n    <Button\n      size=\"small\"\n      type=\"link\"\n      icon={<CloseCircleOutlined />}\n      danger\n      loading={actionLoading === record.id}\n      onClick={() => handleReject(record.id)}\n    >\n      Reject\n    </Button>\n  )}\n\n  {/* Verify button: only shown if email address exists */}\n  {record.representativeEmail && (\n    <Button\n      size=\"small\"\n      type=\"link\"\n      icon={<MailOutlined />}\n      loading={actionLoading === record.id}\n      onClick={() => handleResendVerification(record.id)}\n    >\n      Verify\n    </Button>\n  )}\n\n  {/* Delete button: always shown */}\n  <Popconfirm title=\"Delete this response?\" onConfirm={() => handleDelete(record.id)}>\n    <Button\n      size=\"small\"\n      type=\"link\"\n      icon={<DeleteOutlined />}\n      danger\n      loading={actionLoading === record.id}\n    />\n  </Popconfirm>\n</Space>\n

Pattern: Conditional rendering prevents confusing UI (can't approve an already-approved response).

"},{"location":"v2/frontend/pages/admin/responses-page/#clickable-table-rows","title":"Clickable Table Rows","text":"
<Table\n  columns={columns}\n  dataSource={responses}\n  rowKey=\"id\"\n  loading={loading}\n  scroll={{ x: 900 }}  // Horizontal scroll for narrow screens\n  onRow={(record) => ({\n    onClick: () => setDetailResponse(record),  // Click row \u2192 open drawer\n    style: { cursor: 'pointer' },              // Show pointer cursor\n  })}\n  pagination={{\n    current: pagination.page,\n    pageSize: pagination.limit,\n    total: pagination.total,\n    showSizeChanger: false,\n    onChange: (page) => fetchResponses(page),\n  }}\n/>\n

Pattern: Entire row clickable (except action buttons use stopPropagation to prevent drawer opening when clicking button).

"},{"location":"v2/frontend/pages/admin/responses-page/#detail-drawer-with-conditional-verified-info","title":"Detail Drawer with Conditional Verified Info","text":"
<Drawer\n  title=\"Response Details\"\n  open={!!detailResponse}\n  onClose={() => setDetailResponse(null)}\n  width={520}\n  destroyOnClose\n>\n  {detailResponse && (\n    <Descriptions column={1} bordered size=\"small\">\n      {/* ... other fields */}\n      <Descriptions.Item label=\"Verified\">\n        {detailResponse.isVerified ? (\n          <Space>\n            <SafetyCertificateOutlined style={{ color: '#16a34a' }} />\n            {detailResponse.verifiedBy && `by ${detailResponse.verifiedBy}`}\n            {detailResponse.verifiedAt && ` on ${dayjs(detailResponse.verifiedAt).format('MMM D, YYYY')}`}\n          </Space>\n        ) : 'No'}\n      </Descriptions.Item>\n      <Descriptions.Item label=\"Response Text\">\n        <div style={{ whiteSpace: 'pre-wrap', maxHeight: 300, overflow: 'auto' }}>\n          {detailResponse.responseText}\n        </div>\n      </Descriptions.Item>\n      <Descriptions.Item label=\"Submitted By\">\n        {detailResponse.isAnonymous\n          ? 'Anonymous'\n          : `${detailResponse.submittedByName || '\u2014'} (${detailResponse.submittedByEmail || '\u2014'})`}\n      </Descriptions.Item>\n    </Descriptions>\n  )}\n</Drawer>\n

Pattern: - whiteSpace: 'pre-wrap' preserves line breaks in response text - maxHeight: 300px + overflow: 'auto' for scrolling long responses - Conditional rendering for verified metadata

"},{"location":"v2/frontend/pages/admin/responses-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/responses-page/#debounced-search-400ms","title":"Debounced Search (400ms)","text":"

Longer debounce than other pages: - Response text can be several paragraphs - Users need time to type complete search phrases - Reduces API calls during typing

"},{"location":"v2/frontend/pages/admin/responses-page/#per-row-action-loading_1","title":"Per-Row Action Loading","text":"
const [actionLoading, setActionLoading] = useState<string | null>(null);\n

Benefits: - Only one button shows spinner at a time - Other rows remain interactive - Better UX than disabling entire table

"},{"location":"v2/frontend/pages/admin/responses-page/#usecallback-optimization","title":"useCallback Optimization","text":"
const fetchResponses = useCallback(async (page = 1) => {\n  // ... fetch logic\n}, [statusFilter, campaignFilter, search]);\n

Memoized function prevents unnecessary re-fetches.

"},{"location":"v2/frontend/pages/admin/responses-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/responses-page/#mobile-576px","title":"Mobile (< 576px)","text":""},{"location":"v2/frontend/pages/admin/responses-page/#tablet-576px-992px","title":"Tablet (576px - 992px)","text":""},{"location":"v2/frontend/pages/admin/responses-page/#desktop-992px","title":"Desktop (\u2265 992px)","text":""},{"location":"v2/frontend/pages/admin/responses-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/responses-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/responses-page/#verify-button-not-showing","title":"Verify Button Not Showing","text":"

Problem: Response has email in table, but Verify button missing.

Diagnosis:

Check representativeEmail field:

{record.representativeEmail && (\n  <Button icon={<MailOutlined />} onClick={() => handleResendVerification(record.id)}>\n    Verify\n  </Button>\n)}\n

Common Issues:

  1. Email null in database:
  2. Represent API didn't return email for this representative
  3. Some reps don't have public emails
  4. Solution: Not fixable (representative doesn't have email)

  5. Email not fetched from API:

  6. Check API response includes representativeEmail field
  7. Solution: Ensure backend includes field in response serialization

Solution: Verify button only shown if email exists. No email = no verification possible.

"},{"location":"v2/frontend/pages/admin/responses-page/#approved-response-not-showing-on-public-wall","title":"Approved Response Not Showing on Public Wall","text":"

Problem: Approve response, but doesn't appear on /responses/:campaignId page.

Diagnosis:

Check campaign showResponseWall flag: 1. Navigate to /app/influence/campaigns 2. Edit campaign 3. Verify \"Show Response Wall\" toggle is ON

Common Issues:

  1. Response wall disabled for campaign:
  2. Campaign showResponseWall is false
  3. Solution: Edit campaign, enable Show Response Wall, save

  4. Response not verified:

  5. Some campaigns require verified responses only
  6. Solution: Click Verify button, user clicks email link

  7. Browser cache:

  8. Hard refresh public page (Ctrl+Shift+R)

Solution: Ensure campaign has response wall enabled + response is approved.

"},{"location":"v2/frontend/pages/admin/responses-page/#verification-email-not-sending","title":"Verification Email Not Sending","text":"

Problem: Click Verify button \u2192 Success message \u2192 User doesn't receive email.

Diagnosis:

Check SMTP configuration: 1. Navigate to Settings \u2192 Email tab 2. Verify active provider: Production (not MailHog) 3. Click \"Test Connection\" \u2192 Should succeed

Common Issues:

  1. MailHog active (dev mode):
  2. Check MailHog UI: http://localhost:8025
  3. Email sent to MailHog instead of real inbox
  4. Solution: Switch to Production provider in Settings

  5. SMTP credentials invalid:

  6. Test connection fails
  7. Solution: Update SMTP credentials, re-test

  8. Spam folder:

  9. Email marked as spam
  10. Solution: Check user's spam folder, whitelist sender

Solution: Verify SMTP settings, test connection, check MailHog vs Production.

"},{"location":"v2/frontend/pages/admin/responses-page/#delete-button-deletes-immediately","title":"Delete Button Deletes Immediately","text":"

Problem: Click Delete icon \u2192 Response deletes without confirmation.

Diagnosis:

Check Popconfirm placement:

<Popconfirm title=\"Delete this response?\" onConfirm={() => handleDelete(record.id)}>\n  <Button icon={<DeleteOutlined />} />\n</Popconfirm>\n

Solution: Popconfirm should wrap Button. If missing, delete happens immediately (bad UX).

"},{"location":"v2/frontend/pages/admin/responses-page/#campaign-filter-dropdown-empty","title":"Campaign Filter Dropdown Empty","text":"

Problem: Click Campaign filter \u2192 No options in dropdown.

Diagnosis:

Check campaigns API endpoint:

curl http://localhost:4000/api/campaigns?limit=100\n

Common Issues:

  1. No campaigns created:
  2. Navigate to /app/influence/campaigns
  3. Create at least one campaign
  4. Return to responses page

  5. Campaigns API failing:

  6. Check API logs: docker compose logs api | grep \"campaigns\"
  7. Verify database connection

Solution: Create at least one campaign before filtering responses.

"},{"location":"v2/frontend/pages/admin/responses-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/settings-page/","title":"SettingsPage","text":""},{"location":"v2/frontend/pages/admin/settings-page/#overview","title":"Overview","text":"

The SettingsPage provides a centralized interface for configuring all system-wide settings including organization branding, theme colors, email (SMTP), and feature toggles. It uses a tabbed interface with separate sections for each settings category.

Route: /app/settings Component: admin/src/pages/SettingsPage.tsx (420 lines) Auth Required: Yes (SUPER_ADMIN role recommended for production) Layout: AppLayout

"},{"location":"v2/frontend/pages/admin/settings-page/#screenshot","title":"Screenshot","text":"

[Screenshot: Settings page with 4 tabs (Organization, Theme Colors, Email, Feature Toggles). Currently showing Email tab with sections for Sender configuration, Active SMTP Provider toggle (MailHog vs Production), connection details, and test buttons. At bottom is a large \"Save Settings\" button.]

"},{"location":"v2/frontend/pages/admin/settings-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/settings-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/settings-page/#updating-organization-settings","title":"Updating Organization Settings","text":"
  1. Navigate to /app/settings
  2. Verify \"Organization\" tab is selected (default)
  3. Modify fields:
  4. Organization Name
  5. Short Name (max 10 chars, shown in collapsed sidebar)
  6. Logo URL
  7. Favicon URL
  8. Footer Text
  9. Login Subtitle
  10. Click \"Save Settings\" button at bottom
  11. Success message: \"Settings saved successfully\"
  12. Changes apply immediately (refresh not required)
"},{"location":"v2/frontend/pages/admin/settings-page/#customizing-theme-colors","title":"Customizing Theme Colors","text":"
  1. Click \"Theme Colors\" tab
  2. Modify Admin Theme colors:
  3. Primary Color (ColorPicker)
  4. Background Color (ColorPicker)
  5. Modify Public Theme colors:
  6. Primary Color
  7. Background Color
  8. Container Color
  9. Header Gradient (CSS gradient string)
  10. View live preview swatches below form
  11. Click \"Save Settings\"
  12. Theme updates apply on next page load
"},{"location":"v2/frontend/pages/admin/settings-page/#configuring-smtp-email","title":"Configuring SMTP Email","text":"
  1. Click \"Email\" tab
  2. Set Sender info:
  3. From Name (e.g., \"Changemaker Lite\")
  4. From Address (e.g., \"noreply@cmlite.org\")
  5. Switch SMTP Provider:
  6. Click MailHog or Production segment
  7. Confirmation: \"Switched to [provider] SMTP\"
  8. Configure Production SMTP:
  9. SMTP Host (e.g., smtp.protonmail.ch)
  10. SMTP Port (587 for STARTTLS, 465 for SSL)
  11. SMTP User
  12. SMTP Password
  13. Enable Test Mode (optional):
  14. Toggle \"Enable Test Mode\" switch
  15. Set Test Recipient email
  16. All emails redirect to test recipient
  17. Click \"Save Settings\"
  18. Test configuration:
  19. Click \"Test Connection\" \u2192 Verify \"Connection successful\"
  20. Click \"Send Test Email\" \u2192 Check inbox for test message
"},{"location":"v2/frontend/pages/admin/settings-page/#testing-smtp-configuration","title":"Testing SMTP Configuration","text":"
  1. Navigate to Email tab
  2. Ensure production credentials are saved
  3. Switch to \"Production\" provider
  4. Click \"Test Connection\" button
  5. Wait for result (success/error alert)
  6. If successful, click \"Send Test Email\"
  7. Check email inbox for test message
  8. If failed, review error message and fix credentials
"},{"location":"v2/frontend/pages/admin/settings-page/#enablingdisabling-features","title":"Enabling/Disabling Features","text":"
  1. Click \"Feature Toggles\" tab
  2. Toggle switches:
  3. Enable Influence (campaigns, responses, reps)
  4. Enable Map (locations, cuts, shifts, canvassing)
  5. Enable Newsletter (Listmonk integration)
  6. Enable Landing Pages (page builder)
  7. Info alert: \"Disabling a module hides it from navigation but does not delete data\"
  8. Click \"Save Settings\"
  9. Navigation menu updates to hide/show disabled modules
"},{"location":"v2/frontend/pages/admin/settings-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/settings-page/#ant-design-components-used","title":"Ant Design Components Used","text":""},{"location":"v2/frontend/pages/admin/settings-page/#tab-structure","title":"Tab Structure","text":"
const items = [\n  {\n    key: 'organization',\n    label: 'Organization',\n    icon: <SettingOutlined />,\n    children: (/* Organization form fields */)\n  },\n  {\n    key: 'theme',\n    label: 'Theme Colors',\n    children: (/* Theme form fields */)\n  },\n  {\n    key: 'email',\n    label: 'Email',\n    children: (/* Email form fields */)\n  },\n  {\n    key: 'features',\n    label: 'Feature Toggles',\n    children: (/* Feature toggle switches */)\n  },\n];\n\nreturn (\n  <Form form={form} layout=\"vertical\">\n    <Tabs items={items} />\n    <Button type=\"primary\" icon={<SaveOutlined />} onClick={handleSave}>\n      Save Settings\n    </Button>\n  </Form>\n);\n
"},{"location":"v2/frontend/pages/admin/settings-page/#color-swatch-preview","title":"Color Swatch Preview","text":"
function Swatch({ label, color }: { label: string; color: string }) {\n  return (\n    <div style={{ textAlign: 'center' }}>\n      <div\n        style={{\n          width: 48,\n          height: 48,\n          borderRadius: 8,\n          background: color,\n          border: '2px solid rgba(255,255,255,0.2)',\n          marginBottom: 4,\n        }}\n      />\n      <Text style={{ fontSize: 11 }}>{label}</Text>\n    </div>\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/settings-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/settings-page/#zustand-store-used","title":"Zustand Store Used","text":"
import { useSettingsStore } from '@/stores/settings.store';\n\nconst { settings, loading, fetchAdminSettings, updateSettings } = useSettingsStore();\n\nuseEffect(() => {\n  fetchAdminSettings();\n}, [fetchAdminSettings]);\n
"},{"location":"v2/frontend/pages/admin/settings-page/#local-state","title":"Local State","text":"
const [form] = Form.useForm();\nconst [testingConnection, setTestingConnection] = useState(false);\nconst [connectionResult, setConnectionResult] = useState<SmtpTestResult | null>(null);\nconst [sendingTest, setSendingTest] = useState(false);\nconst [sendResult, setSendResult] = useState<SmtpSendTestResult | null>(null);\n
"},{"location":"v2/frontend/pages/admin/settings-page/#form-initialization","title":"Form Initialization","text":"
useEffect(() => {\n  if (settings) {\n    form.setFieldsValue(settings);\n  }\n}, [settings, form]);\n

When settings load from store, form automatically populates with current values.

"},{"location":"v2/frontend/pages/admin/settings-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/settings-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose GET /api/settings Load settings (via store) PUT /api/settings Update settings POST /api/settings/email/test-connection Test SMTP connection POST /api/settings/email/test-send Send test email"},{"location":"v2/frontend/pages/admin/settings-page/#save-settings","title":"Save Settings","text":"
const handleSave = async () => {\n  try {\n    const values = form.getFieldsValue();\n\n    // Convert ColorPicker values to hex strings\n    const colorFields = [\n      'adminColorPrimary',\n      'adminColorBgBase',\n      'publicColorPrimary',\n      'publicColorBgBase',\n      'publicColorBgContainer',\n    ] as const;\n\n    for (const field of colorFields) {\n      const val = values[field];\n      if (val && typeof val === 'object' && 'toHexString' in val) {\n        values[field] = val.toHexString();\n      }\n    }\n\n    await updateSettings(values);\n    setConnectionResult(null);\n    setSendResult(null);\n    message.success('Settings saved successfully');\n  } catch {\n    message.error('Failed to save settings');\n  }\n};\n

Request Payload:

{\n  \"organizationName\": \"Changemaker Lite\",\n  \"organizationShortName\": \"CML\",\n  \"organizationLogoUrl\": \"https://example.com/logo.png\",\n  \"smtpHost\": \"smtp.protonmail.ch\",\n  \"smtpPort\": 587,\n  \"smtpUser\": \"user@example.com\",\n  \"smtpPass\": \"***\",\n  \"smtpActiveProvider\": \"production\",\n  \"adminColorPrimary\": \"#1890ff\",\n  \"publicColorPrimary\": \"#3498db\",\n  \"enableInfluence\": true,\n  \"enableMap\": true\n}\n
"},{"location":"v2/frontend/pages/admin/settings-page/#test-smtp-connection","title":"Test SMTP Connection","text":"
const handleTestConnection = async () => {\n  setTestingConnection(true);\n  setConnectionResult(null);\n  try {\n    const { data } = await api.post<SmtpTestResult>('/settings/email/test-connection');\n    setConnectionResult(data);\n  } catch {\n    setConnectionResult({ success: false, message: 'Request failed' });\n  } finally {\n    setTestingConnection(false);\n  }\n};\n

Response (Success):

{\n  \"success\": true,\n  \"message\": \"Connection successful\"\n}\n

Response (Failure):

{\n  \"success\": false,\n  \"message\": \"Connection failed: Authentication failed\"\n}\n
"},{"location":"v2/frontend/pages/admin/settings-page/#send-test-email","title":"Send Test Email","text":"
const handleSendTest = async () => {\n  setSendingTest(true);\n  setSendResult(null);\n  try {\n    const to = form.getFieldValue('testEmailRecipient');\n    const { data } = await api.post<SmtpSendTestResult>('/settings/email/test-send', { to });\n    setSendResult(data);\n  } catch {\n    setSendResult({ success: false, testMode: false, recipient: '' });\n  } finally {\n    setSendingTest(false);\n  }\n};\n

Request:

{\n  \"to\": \"admin@example.com\"\n}\n

Response (Success):

{\n  \"success\": true,\n  \"testMode\": false,\n  \"recipient\": \"admin@example.com\",\n  \"messageId\": \"<abc123@smtp.protonmail.ch>\"\n}\n
"},{"location":"v2/frontend/pages/admin/settings-page/#toggle-smtp-provider","title":"Toggle SMTP Provider","text":"
const handleProviderToggle = async (value: string | number) => {\n  const provider = value as 'mailhog' | 'production';\n  try {\n    await updateSettings({ smtpActiveProvider: provider });\n    form.setFieldsValue({ smtpActiveProvider: provider });\n    message.success(`Switched to ${provider === 'mailhog' ? 'MailHog' : 'Production'} SMTP`);\n    setConnectionResult(null);\n    setSendResult(null);\n  } catch {\n    message.error('Failed to switch SMTP provider');\n  }\n};\n

Why clear test results?

Test results are provider-specific. Switching providers invalidates previous test results.

"},{"location":"v2/frontend/pages/admin/settings-page/#colorpicker-integration","title":"ColorPicker Integration","text":""},{"location":"v2/frontend/pages/admin/settings-page/#converting-color-values","title":"Converting Color Values","text":"

Ant Design ColorPicker returns an object with toHexString() method:

// ColorPicker value\nconst colorValue = {\n  toHexString: () => '#1890ff',\n  // ... other methods\n};\n\n// Convert before saving\nfor (const field of colorFields) {\n  const val = values[field];\n  if (val && typeof val === 'object' && 'toHexString' in val) {\n    values[field] = val.toHexString();\n  }\n}\n
"},{"location":"v2/frontend/pages/admin/settings-page/#theme-preview","title":"Theme Preview","text":"
{settings && (\n  <div style={{ marginTop: 24 }}>\n    <Text strong style={{ fontSize: 15 }}>Preview</Text>\n    <Divider style={{ margin: '12px 0' }} />\n    <div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>\n      <Swatch label=\"Admin Primary\" color={settings.adminColorPrimary} />\n      <Swatch label=\"Admin BG\" color={settings.adminColorBgBase} />\n      <Swatch label=\"Public Primary\" color={settings.publicColorPrimary} />\n      <Swatch label=\"Public BG\" color={settings.publicColorBgBase} />\n      <Swatch label=\"Public Container\" color={settings.publicColorBgContainer} />\n    </div>\n    <div\n      style={{\n        marginTop: 12,\n        padding: '12px 24px',\n        background: settings.publicHeaderGradient,\n        borderRadius: 8,\n        color: '#fff',\n        fontWeight: 600,\n      }}\n    >\n      Header Gradient Preview\n    </div>\n  </div>\n)}\n
"},{"location":"v2/frontend/pages/admin/settings-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/settings-page/#single-form-instance","title":"Single Form Instance","text":"

All settings use one form instance:

const [form] = Form.useForm();\n\n<Form form={form} layout=\"vertical\">\n  <Tabs items={items} />\n  <Button onClick={handleSave}>Save Settings</Button>\n</Form>\n

Benefits:

"},{"location":"v2/frontend/pages/admin/settings-page/#optimistic-provider-switching","title":"Optimistic Provider Switching","text":"

Provider toggle updates immediately without waiting for API:

await updateSettings({ smtpActiveProvider: provider });\nform.setFieldsValue({ smtpActiveProvider: provider });  // Update form immediately\nmessage.success(`Switched to ${provider}`);\n

Why optimistic?

"},{"location":"v2/frontend/pages/admin/settings-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/settings-page/#smtp-test-connection-failing","title":"SMTP Test Connection Failing","text":"

Problem: Click \"Test Connection\" \u2192 Error: \"Connection failed: Authentication failed\"

Diagnosis:

Check SMTP credentials:

{\n  smtpHost: \"smtp.protonmail.ch\",\n  smtpPort: 587,\n  smtpUser: \"user@protonmail.com\",\n  smtpPass: \"***\"\n}\n

Common Issues:

  1. Wrong port:
  2. Use 587 for STARTTLS
  3. Use 465 for SSL/TLS
  4. Port 25 often blocked by ISPs

  5. App-specific password required:

  6. Gmail requires app-specific passwords (not account password)
  7. ProtonMail requires ProtonMail Bridge for SMTP

  8. Wrong provider selected:

  9. Ensure \"Production\" is selected before testing production credentials

Solution:

  1. Verify credentials with email provider documentation
  2. Switch to \"Production\" provider
  3. Save settings before testing
  4. Check firewall rules (port 587/465 outbound)
"},{"location":"v2/frontend/pages/admin/settings-page/#theme-colors-not-applying","title":"Theme Colors Not Applying","text":"

Problem: Change colors, save settings, but theme doesn't update.

Diagnosis:

Check if page reload is required:

// Theme updates apply on NEXT page load, not immediately\nawait updateSettings({ adminColorPrimary: '#ff0000' });\n// Current page still shows old color\n

Solution:

Refresh page after saving theme changes:

const handleSave = async () => {\n  await updateSettings(values);\n  message.success('Settings saved. Refreshing page...');\n  setTimeout(() => window.location.reload(), 1000);\n};\n
"},{"location":"v2/frontend/pages/admin/settings-page/#feature-toggle-not-hiding-module","title":"Feature Toggle Not Hiding Module","text":"

Problem: Disable \"Enable Influence\" toggle, save, but Influence menu items still visible.

Diagnosis:

Check AppLayout navigation logic:

// AppLayout should check settings.enableInfluence\n{settings.enableInfluence && (\n  <SubMenu key=\"influence\" title=\"Influence\">\n    {/* Influence menu items */}\n  </SubMenu>\n)}\n

Solution:

Ensure AppLayout reads settings from store and conditionally renders menu items.

"},{"location":"v2/frontend/pages/admin/settings-page/#test-email-not-sending","title":"Test Email Not Sending","text":"

Problem: Click \"Send Test Email\" \u2192 Success message, but no email in inbox.

Diagnosis:

  1. Check active provider:

    settings.smtpActiveProvider === 'mailhog' // MailHog (dev)\nsettings.smtpActiveProvider === 'production' // Real SMTP\n

  2. Check test mode:

    settings.emailTestMode === true // All emails redirect to testEmailRecipient\n

  3. Check spam folder

  4. Check MailHog web UI (http://localhost:8025) if MailHog is active

Solution:

"},{"location":"v2/frontend/pages/admin/settings-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/shifts-page/","title":"ShiftsPage","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#overview","title":"Overview","text":"

The ShiftsPage provides complete volunteer shift management for the Map module, enabling administrators to schedule canvassing shifts, manage volunteer signups, and coordinate field operations. Features include date/time scheduling, volunteer capacity tracking, public shift publishing, area (cut) assignment, signup management, and bulk email notifications to confirmed volunteers.

Route: /app/map/shifts Component: admin/src/pages/ShiftsPage.tsx (757 lines) Auth Required: Yes (SUPER_ADMIN, MAP_ADMIN roles) Layout: AppLayout

"},{"location":"v2/frontend/pages/admin/shifts-page/#screenshot","title":"Screenshot","text":"

[Screenshot: Shifts page with 6 statistics cards at top (Total, Open, Full, Cancelled, Upcoming, Signups) showing counts with colored icons. Below stats are search bar + status filter dropdown. Main table shows columns: Title, Date, Time (start \u2014 end), Location, Area (cut name), Volunteers (progress bar showing X/Y), Status (colored tags), Public (checkmark icon if true), Actions (edit + delete). Rows clickable to open signups drawer. Page header has \"Create Shift\" button.]

"},{"location":"v2/frontend/pages/admin/shifts-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#core-features","title":"Core Features","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#signup-management","title":"Signup Management","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#shift-status-workflow","title":"Shift Status Workflow","text":"
  1. OPEN \u2014 Shift created, accepting signups
  2. FULL \u2014 Max volunteers reached (currentVolunteers >= maxVolunteers)
  3. CANCELLED \u2014 Shift cancelled by admin
  4. COMPLETED \u2014 Shift date passed (auto-marked by backend)
"},{"location":"v2/frontend/pages/admin/shifts-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#viewing-shifts-list","title":"Viewing Shifts List","text":"
  1. Navigate to /app/map/shifts
  2. Page loads with statistics cards at top:
  3. Total: All shifts count
  4. Open: Shifts accepting signups (green)
  5. Full: Shifts at capacity (orange)
  6. Cancelled: Admin-cancelled shifts (red)
  7. Upcoming: Future shifts (blue)
  8. Signups: Total confirmed volunteers across all shifts
  9. Table shows first 20 shifts (paginated)
  10. View shift details:
  11. Title (bold)
  12. Date (YYYY-MM-DD)
  13. Time (HH:mm \u2014 HH:mm format)
  14. Location (e.g., \"Campaign HQ, 123 Main St\")
  15. Area (cut name if assigned)
  16. Volunteers (progress bar X/Y)
  17. Status tag (color-coded)
  18. Public checkmark icon (if published)
  19. Actions (edit, delete)
  20. Click any row to open signups drawer
"},{"location":"v2/frontend/pages/admin/shifts-page/#creating-a-shift","title":"Creating a Shift","text":"
  1. Click \"Create Shift\" button in page header
  2. Modal opens (560px width) with vertical form
  3. Fill required fields:
  4. Title \u2014 Shift name (e.g., \"Door Knocking\", \"Phone Banking\")
  5. Date \u2014 Date picker (calendar popup)
  6. Start Time \u2014 Time picker (HH:mm, 5-minute intervals)
  7. End Time \u2014 Time picker (HH:mm, 5-minute intervals)
  8. Max Volunteers \u2014 Number input (min: 1)
  9. Fill optional fields:
  10. Description \u2014 Multi-line text (shift details + instructions)
  11. Location \u2014 Text (e.g., \"Campaign HQ, 123 Main St\")
  12. Area (Cut) \u2014 Dropdown (select canvass area, searchable)
  13. Public \u2014 Switch toggle (default: false)
  14. Click \"Create\" button
  15. Success message: \"Shift created\"
  16. Modal closes, table refreshes to page 1, stats refresh
  17. New shift appears with status OPEN
"},{"location":"v2/frontend/pages/admin/shifts-page/#editing-a-shift","title":"Editing a Shift","text":"
  1. Locate shift in table
  2. Click Edit icon button (EditOutlined) in Actions column
  3. Drawer opens on right side (520px width) with vertical form
  4. Modify any fields (same as create, plus Status dropdown):
  5. Status options: OPEN, FULL, CANCELLED, COMPLETED
  6. Click \"Save\" button in drawer header
  7. Success message: \"Shift updated\"
  8. Drawer closes, table refreshes, stats refresh
"},{"location":"v2/frontend/pages/admin/shifts-page/#publishing-a-shift-to-public-page","title":"Publishing a Shift to Public Page","text":"
  1. Open shift in edit drawer
  2. Toggle \"Public\" switch to ON
  3. Click \"Save\"
  4. Shift now visible on public /shifts page
  5. Users can self-signup via public page
  6. Signups source tracked as PUBLIC
"},{"location":"v2/frontend/pages/admin/shifts-page/#assigning-a-cut-area-to-shift","title":"Assigning a Cut (Area) to Shift","text":"
  1. Open shift in edit drawer
  2. Click \"Area (Cut)\" dropdown
  3. Search for cut by name
  4. Select cut from list
  5. Click \"Save\"
  6. Volunteer portal integration:
  7. Volunteers assigned to this shift now see it in /volunteer/assignments page
  8. Shift with cut enables volunteer canvassing workflow
  9. No cut = general shift (no canvass area)
"},{"location":"v2/frontend/pages/admin/shifts-page/#viewing-shift-signups","title":"Viewing Shift Signups","text":"
  1. Click any shift row in table
  2. Signups drawer opens on right side (640px width)
  3. Drawer header shows:
  4. TeamOutlined icon + \"Signups \u2014 {Shift Title}\"
  5. \"Email All\" button in header (disabled if no confirmed volunteers)
  6. Info card at top displays shift summary:
  7. Date
  8. Time (start \u2014 end)
  9. Volunteers (current / max)
  10. Table shows confirmed volunteers:
  11. Columns: Email, Name, Phone, Source (PUBLIC/ADMIN tag), Date, Remove button
  12. Pagination: 20 per page (if > 20 signups)
  13. Cancelled signups hidden (filtered out)
  14. Add volunteer section at bottom:
  15. Email input (required)
  16. Name input (optional)
  17. \"Add\" button (disabled if email empty)
"},{"location":"v2/frontend/pages/admin/shifts-page/#manually-adding-a-volunteer","title":"Manually Adding a Volunteer","text":"
  1. Open signups drawer for any shift
  2. Scroll to bottom \"Add volunteer\" section
  3. Enter email (required)
  4. Enter name (optional)
  5. Click \"Add\" button
  6. Backend logic:
  7. If user exists: Create ShiftSignup record
  8. If user doesn't exist: Create temp User + ShiftSignup
  9. Signup source: ADMIN
  10. Signup status: CONFIRMED
  11. Success message: \"Volunteer added\"
  12. Table refreshes with new volunteer
  13. Email and name inputs clear
  14. Main shifts table progress bar updates

Temp user creation: - Role: TEMP - Email: provided email - Password: Readable format (e.g., \"BlueEagle42\") - Expires: shift date + 1 day - Used for public signups without account

"},{"location":"v2/frontend/pages/admin/shifts-page/#removing-a-volunteer","title":"Removing a Volunteer","text":"
  1. Open signups drawer
  2. Locate volunteer in table
  3. Click Delete icon button (red, last column)
  4. Popconfirm: \"Remove this volunteer?\"
  5. Click \"OK\"
  6. Success message: \"Volunteer removed\"
  7. Table refreshes (volunteer row disappears)
  8. Main shifts table progress bar updates
  9. Shift status may change from FULL to OPEN if capacity now available
"},{"location":"v2/frontend/pages/admin/shifts-page/#emailing-all-volunteers","title":"Emailing All Volunteers","text":"
  1. Open signups drawer with confirmed volunteers
  2. Click \"Email All\" button in drawer header
  3. Backend sends email to all confirmed volunteers:
  4. Email template: Shift details (title, date, time, location, description)
  5. Subject: \"Shift Reminder: {Shift Title}\"
  6. From: Site settings sender (e.g., \"Changemaker Lite noreply@cmlite.org\")
  7. Success message: \"Emailed N volunteer(s)\" (or \"N sent, M failed\" if failures)
  8. Email uses SMTP settings from Settings page
"},{"location":"v2/frontend/pages/admin/shifts-page/#searching-and-filtering","title":"Searching and Filtering","text":"
  1. Search bar (top left):
  2. Type title or location keywords
  3. 300ms debounce (waits for typing pause)
  4. Search resets pagination to page 1
  5. Status filter dropdown (top right):
  6. Select OPEN, FULL, CANCELLED, or COMPLETED
  7. Filter resets pagination to page 1
  8. Clear to show all shifts
  9. Filters persist during pagination
"},{"location":"v2/frontend/pages/admin/shifts-page/#deleting-a-shift","title":"Deleting a Shift","text":"
  1. Locate shift in table
  2. Click Delete icon button (DeleteOutlined) in Actions column
  3. Popconfirm: \"Delete this shift?\"
  4. Click \"OK\" to confirm
  5. Success message: \"Shift deleted\"
  6. Table refreshes, stats refresh
  7. Cascade behavior: All ShiftSignup records also deleted (Prisma cascade)
"},{"location":"v2/frontend/pages/admin/shifts-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#ant-design-components-used","title":"Ant Design Components Used","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#table-columns-main-shifts-table","title":"Table Columns (Main Shifts Table)","text":"
const columns: ColumnsType<Shift> = [\n  {\n    title: 'Title',\n    dataIndex: 'title',\n    render: (title) => <span style={{ fontWeight: 500 }}>{title}</span>,\n  },\n  {\n    title: 'Date',\n    dataIndex: 'date',\n    render: (date) => dayjs(date).format('YYYY-MM-DD'),\n  },\n  {\n    title: 'Time',\n    render: (_, record) => `${record.startTime} \u2014 ${record.endTime}`,\n    responsive: ['md'],\n  },\n  {\n    title: 'Location',\n    dataIndex: 'location',\n    render: (loc) => loc || '--',\n    responsive: ['lg'],\n  },\n  {\n    title: 'Area',\n    render: (_, record) => record.cut?.name || '--',\n    responsive: ['md'],\n  },\n  {\n    title: 'Volunteers',\n    width: 140,\n    render: (_, record) => {\n      const confirmed = record._count?.signups ?? record.currentVolunteers;\n      const pct = record.maxVolunteers > 0 ? Math.round((confirmed / record.maxVolunteers) * 100) : 0;\n      return (\n        <Progress\n          percent={pct}\n          size=\"small\"\n          status={pct >= 100 ? 'exception' : 'active'}\n          format={() => `${confirmed}/${record.maxVolunteers}`}\n        />\n      );\n    },\n  },\n  {\n    title: 'Status',\n    dataIndex: 'status',\n    width: 100,\n    render: (status) => <Tag color={SHIFT_STATUS_COLORS[status]}>{SHIFT_STATUS_LABELS[status]}</Tag>,\n  },\n  {\n    title: 'Public',\n    dataIndex: 'isPublic',\n    width: 70,\n    render: (isPublic) => isPublic ? <CheckCircleOutlined style={{ color: '#52c41a' }} /> : null,\n  },\n  {\n    title: 'Actions',\n    width: 120,\n    render: (_, record) => (\n      <Space>\n        <Button type=\"link\" size=\"small\" icon={<EditOutlined />} onClick={() => openEdit(record)} />\n        <Popconfirm title=\"Delete this shift?\" onConfirm={() => handleDelete(record.id)}>\n          <Button type=\"link\" size=\"small\" danger icon={<DeleteOutlined />} />\n        </Popconfirm>\n      </Space>\n    ),\n  },\n];\n

Key patterns: - _count.signups aggregation from Prisma (confirmed volunteers count) - responsive array hides columns on smaller screens - Progress bar shows visual capacity indicator (turns red when full) - onRow prop makes entire row clickable to open signups drawer

"},{"location":"v2/frontend/pages/admin/shifts-page/#signups-table-columns","title":"Signups Table Columns","text":"
const signupColumns: ColumnsType<ShiftSignup> = [\n  {\n    title: 'Email',\n    dataIndex: 'userEmail',\n  },\n  {\n    title: 'Name',\n    dataIndex: 'userName',\n    render: (name) => name || '--',\n  },\n  {\n    title: 'Phone',\n    render: (_, record) => record.userPhone || record.user?.phone || '--',\n    responsive: ['md'],\n  },\n  {\n    title: 'Source',\n    dataIndex: 'signupSource',\n    width: 100,\n    render: (source) => <Tag color={SIGNUP_SOURCE_COLORS[source]}>{source}</Tag>,\n  },\n  {\n    title: 'Date',\n    dataIndex: 'signupDate',\n    render: (date) => dayjs(date).format('YYYY-MM-DD'),\n    responsive: ['lg'],\n  },\n  {\n    title: '',\n    width: 60,\n    render: (_, record) =>\n      record.status === 'CONFIRMED' ? (\n        <Popconfirm title=\"Remove this volunteer?\" onConfirm={() => handleRemoveSignup(record.id)}>\n          <Button type=\"link\" size=\"small\" danger icon={<DeleteOutlined />} />\n        </Popconfirm>\n      ) : (\n        <Tag color=\"red\">Cancelled</Tag>\n      ),\n  },\n];\n

Filter: Table only shows CONFIRMED signups:

<Table dataSource={signups.filter((s) => s.status === 'CONFIRMED')} />\n

"},{"location":"v2/frontend/pages/admin/shifts-page/#status-colors","title":"Status Colors","text":"
export const SHIFT_STATUS_COLORS: Record<ShiftStatus, string> = {\n  OPEN: 'green',\n  FULL: 'orange',\n  CANCELLED: 'red',\n  COMPLETED: 'default',\n};\n\nexport const SHIFT_STATUS_LABELS: Record<ShiftStatus, string> = {\n  OPEN: 'Open',\n  FULL: 'Full',\n  CANCELLED: 'Cancelled',\n  COMPLETED: 'Completed',\n};\n
"},{"location":"v2/frontend/pages/admin/shifts-page/#signup-source-colors","title":"Signup Source Colors","text":"
export const SIGNUP_SOURCE_COLORS = {\n  PUBLIC: 'blue',    // User signed up via public /shifts page\n  ADMIN: 'purple',   // Admin added manually\n};\n
"},{"location":"v2/frontend/pages/admin/shifts-page/#form-fields","title":"Form Fields","text":"
const shiftFormFields = (isEdit = false) => (\n  <>\n    <Form.Item name=\"title\" label=\"Title\" rules={[{ required: true }]}>\n      <Input placeholder=\"e.g. Door Knocking, Phone Banking\" />\n    </Form.Item>\n    <Form.Item name=\"description\" label=\"Description\">\n      <Input.TextArea rows={3} placeholder=\"Shift details and instructions\" />\n    </Form.Item>\n    <Row gutter={12}>\n      <Col xs={24} sm={8}>\n        <Form.Item name=\"date\" label=\"Date\" rules={[{ required: true }]}>\n          <DatePicker style={{ width: '100%' }} />\n        </Form.Item>\n      </Col>\n      <Col xs={12} sm={8}>\n        <Form.Item name=\"startTime\" label=\"Start Time\" rules={[{ required: true }]}>\n          <TimePicker format=\"HH:mm\" style={{ width: '100%' }} minuteStep={5} />\n        </Form.Item>\n      </Col>\n      <Col xs={12} sm={8}>\n        <Form.Item name=\"endTime\" label=\"End Time\" rules={[{ required: true }]}>\n          <TimePicker format=\"HH:mm\" style={{ width: '100%' }} minuteStep={5} />\n        </Form.Item>\n      </Col>\n    </Row>\n    <Form.Item name=\"location\" label=\"Location\">\n      <Input placeholder=\"e.g. Campaign HQ, 123 Main St\" />\n    </Form.Item>\n    <Form.Item name=\"cutId\" label=\"Area (Cut)\">\n      <Select\n        options={cutOptions}\n        placeholder=\"Assign a canvass area...\"\n        allowClear\n        showSearch\n        optionFilterProp=\"label\"\n      />\n    </Form.Item>\n    <Row gutter={12}>\n      <Col xs={12} sm={8}>\n        <Form.Item name=\"maxVolunteers\" label=\"Max Volunteers\" rules={[{ required: true }]}>\n          <InputNumber min={1} style={{ width: '100%' }} />\n        </Form.Item>\n      </Col>\n      <Col xs={12} sm={8}>\n        <Form.Item name=\"isPublic\" label=\"Public\" valuePropName=\"checked\">\n          <Switch />\n        </Form.Item>\n      </Col>\n      {isEdit && (\n        <Col xs={12} sm={8}>\n          <Form.Item name=\"status\" label=\"Status\">\n            <Select options={statusOptions} />\n          </Form.Item>\n        </Col>\n      )}\n    </Row>\n  </>\n);\n

Reusable pattern: Same form fields for create + edit, with conditional Status field in edit mode.

"},{"location":"v2/frontend/pages/admin/shifts-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#zustand-stores-used","title":"Zustand Stores Used","text":"

None \u2014 Shifts fetched from API on each interaction. No global state required.

"},{"location":"v2/frontend/pages/admin/shifts-page/#local-state","title":"Local State","text":"
const [shifts, setShifts] = useState<Shift[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });\nconst [loading, setLoading] = useState(false);\nconst [search, setSearch] = useState('');\nconst [debouncedSearch, setDebouncedSearch] = useState('');\nconst searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\nconst [statusFilter, setStatusFilter] = useState<ShiftStatus | undefined>();\nconst [stats, setStats] = useState<ShiftStats | null>(null);\n\n// Create modal\nconst [createModalOpen, setCreateModalOpen] = useState(false);\nconst [createForm] = Form.useForm();\n\n// Edit drawer\nconst [editDrawerOpen, setEditDrawerOpen] = useState(false);\nconst [editingShift, setEditingShift] = useState<Shift | null>(null);\nconst [editForm] = Form.useForm();\n\n// Signups drawer\nconst [signupsDrawerOpen, setSignupsDrawerOpen] = useState(false);\nconst [signupsShift, setSignupsShift] = useState<Shift | null>(null);\nconst [signups, setSignups] = useState<ShiftSignup[]>([]);\nconst [signupsLoading, setSignupsLoading] = useState(false);\nconst [addEmail, setAddEmail] = useState('');\nconst [addName, setAddName] = useState('');\n\n// Cuts for area dropdown\nconst [cuts, setCuts] = useState<Cut[]>([]);\n
"},{"location":"v2/frontend/pages/admin/shifts-page/#debounced-search","title":"Debounced Search","text":"
const handleSearchChange = (value: string) => {\n  setSearch(value);               // Update input immediately\n  clearTimeout(searchTimerRef.current);\n  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);\n};\n\nuseEffect(() => {\n  return () => clearTimeout(searchTimerRef.current);  // Cleanup on unmount\n}, []);\n\nuseEffect(() => {\n  fetchShifts({ page: 1 });\n  fetchStats();\n  fetchCuts();\n}, [debouncedSearch, statusFilter]);  // Re-fetch when search or filter changes\n

Why 300ms? Same pattern as other pages \u2014 prevents API spam while typing.

"},{"location":"v2/frontend/pages/admin/shifts-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose GET /api/map/shifts List shifts (paginated, filtered) GET /api/map/shifts/stats Fetch statistics (counts by status) GET /api/map/shifts/:id Fetch single shift with signups POST /api/map/shifts Create shift PUT /api/map/shifts/:id Update shift DELETE /api/map/shifts/:id Delete shift (cascade signups) POST /api/map/shifts/:id/signups Add volunteer manually (admin) DELETE /api/map/shifts/:id/signups/:signupId Remove volunteer POST /api/map/shifts/:id/email-details Email all confirmed volunteers"},{"location":"v2/frontend/pages/admin/shifts-page/#list-shifts","title":"List Shifts","text":"

Request:

const { data } = await api.get<ShiftsListResponse>('/map/shifts', {\n  params: {\n    page: 1,\n    limit: 20,\n    search: 'door knocking',    // Optional: search title/location\n    status: 'OPEN',             // Optional: filter by status\n  },\n});\n

Response:

{\n  \"shifts\": [\n    {\n      \"id\": \"shift-123\",\n      \"title\": \"Door Knocking \u2014 Downtown\",\n      \"description\": \"Focus on high-density residential areas. Bring campaign materials.\",\n      \"date\": \"2026-02-15\",\n      \"startTime\": \"10:00\",\n      \"endTime\": \"14:00\",\n      \"location\": \"Campaign HQ, 123 Main St\",\n      \"maxVolunteers\": 15,\n      \"currentVolunteers\": 8,\n      \"status\": \"OPEN\",\n      \"isPublic\": true,\n      \"cutId\": \"cut-456\",\n      \"cut\": {\n        \"id\": \"cut-456\",\n        \"name\": \"Downtown Core\"\n      },\n      \"createdAt\": \"2026-01-10T09:00:00.000Z\",\n      \"updatedAt\": \"2026-01-15T14:30:00.000Z\",\n      \"_count\": {\n        \"signups\": 8\n      }\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 47,\n    \"totalPages\": 3\n  }\n}\n

Key fields: - cutId \u2014 Foreign key to Cut (polygon area) - cut \u2014 Nested cut object (if assigned) - currentVolunteers \u2014 Confirmed signups count - _count.signups \u2014 Prisma aggregation (confirmed signups count) - startTime, endTime \u2014 24-hour format strings (HH:mm)

"},{"location":"v2/frontend/pages/admin/shifts-page/#fetch-shift-statistics","title":"Fetch Shift Statistics","text":"

Request:

const { data } = await api.get<ShiftStats>('/map/shifts/stats');\n

Response:

{\n  \"total\": 47,\n  \"open\": 23,\n  \"full\": 8,\n  \"cancelled\": 2,\n  \"completed\": 14,\n  \"upcoming\": 31,\n  \"totalSignups\": 287\n}\n

Upcoming calculation: Future shifts (date >= today)

"},{"location":"v2/frontend/pages/admin/shifts-page/#create-shift","title":"Create Shift","text":"

Request:

const payload = {\n  title: \"Phone Banking\",\n  description: \"Call voters to discuss campaign issues\",\n  date: \"2026-02-20\",\n  startTime: \"18:00\",\n  endTime: \"21:00\",\n  location: \"Campaign HQ\",\n  maxVolunteers: 10,\n  isPublic: true,\n  cutId: \"cut-789\",  // Optional\n};\n\nawait api.post('/map/shifts', payload);\n

Response:

{\n  \"id\": \"shift-999\",\n  \"title\": \"Phone Banking\",\n  \"status\": \"OPEN\",\n  \"currentVolunteers\": 0,\n  \"createdAt\": \"2026-02-11T10:00:00.000Z\",\n  // ... all other fields\n}\n

Default values: - status \u2014 OPEN - currentVolunteers \u2014 0 - isPublic \u2014 false (if not specified)

"},{"location":"v2/frontend/pages/admin/shifts-page/#update-shift","title":"Update Shift","text":"

Request:

const payload = {\n  status: \"CANCELLED\",\n  description: \"Cancelled due to weather\",\n};\n\nawait api.put(`/map/shifts/${shiftId}`, payload);\n

Partial updates: Only send changed fields.

"},{"location":"v2/frontend/pages/admin/shifts-page/#add-volunteer-manual-signup","title":"Add Volunteer (Manual Signup)","text":"

Request:

await api.post(`/map/shifts/${shiftId}/signups`, {\n  userEmail: 'volunteer@example.com',\n  userName: 'Jane Doe',  // Optional\n});\n

Response:

{\n  \"id\": \"signup-456\",\n  \"shiftId\": \"shift-123\",\n  \"userId\": \"user-789\",  // Existing or newly created temp user\n  \"userEmail\": \"volunteer@example.com\",\n  \"userName\": \"Jane Doe\",\n  \"signupSource\": \"ADMIN\",\n  \"status\": \"CONFIRMED\",\n  \"signupDate\": \"2026-02-11T10:30:00.000Z\"\n}\n

Temp user creation logic: - If volunteer@example.com exists \u2192 link to existing user - If doesn't exist \u2192 create temp user:

{\n  \"email\": \"volunteer@example.com\",\n  \"name\": \"Jane Doe\",\n  \"role\": \"TEMP\",\n  \"password\": \"BlueEagle42\",  // Readable password\n  \"tempUserExpiresAt\": \"2026-02-21T00:00:00.000Z\"  // shift date + 1 day\n}\n

"},{"location":"v2/frontend/pages/admin/shifts-page/#email-all-volunteers","title":"Email All Volunteers","text":"

Request:

const { data } = await api.post<{ sent: number; failed: number }>(\n  `/map/shifts/${shiftId}/email-details`\n);\n

Response:

{\n  \"sent\": 8,\n  \"failed\": 0\n}\n

Email template:

Subject: Shift Reminder: {Shift Title}\n\nHi {Volunteer Name},\n\nThis is a reminder about your upcoming volunteer shift:\n\nTitle: {Shift Title}\nDate: {Date}\nTime: {Start Time} \u2014 {End Time}\nLocation: {Location}\n\nDescription:\n{Shift Description}\n\nThank you for volunteering!\n\nChangemaker Lite\n

SMTP: Uses site settings (Settings page \u2192 Email tab)

"},{"location":"v2/frontend/pages/admin/shifts-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#clickable-table-rows","title":"Clickable Table Rows","text":"
<Table\n  columns={columns}\n  dataSource={shifts}\n  rowKey=\"id\"\n  onRow={(record) => ({\n    onClick: () => openSignups(record),  // Click row \u2192 open signups drawer\n    style: { cursor: 'pointer' },        // Show pointer cursor on hover\n  })}\n/>\n

Pattern: Entire row clickable except action buttons (edit/delete use stopPropagation).

"},{"location":"v2/frontend/pages/admin/shifts-page/#progress-bar-for-volunteer-capacity","title":"Progress Bar for Volunteer Capacity","text":"
{\n  title: 'Volunteers',\n  width: 140,\n  render: (_, record) => {\n    const confirmed = record._count?.signups ?? record.currentVolunteers;\n    const pct = record.maxVolunteers > 0 ? Math.round((confirmed / record.maxVolunteers) * 100) : 0;\n    return (\n      <Progress\n        percent={pct}\n        size=\"small\"\n        status={pct >= 100 ? 'exception' : 'active'}\n        format={() => `${confirmed}/${record.maxVolunteers}`}\n      />\n    );\n  },\n}\n

Visual feedback: - Green progress bar: < 100% - Red progress bar: = 100% (full, status \"exception\") - Format shows: \"8/15\" (8 confirmed out of 15 max)

"},{"location":"v2/frontend/pages/admin/shifts-page/#datetime-payload-formatting","title":"Date/Time Payload Formatting","text":"
const handleCreate = async (values: Record<string, unknown>) => {\n  const payload = {\n    title: values.title,\n    date: dayjs(values.date as string).format('YYYY-MM-DD'),      // DatePicker \u2192 string\n    startTime: dayjs(values.startTime as string).format('HH:mm'), // TimePicker \u2192 string\n    endTime: dayjs(values.endTime as string).format('HH:mm'),     // TimePicker \u2192 string\n    maxVolunteers: values.maxVolunteers,\n    // ... other fields\n  };\n  await api.post('/map/shifts', payload);\n};\n

Why format? DatePicker and TimePicker return Dayjs objects. Backend expects ISO date string + HH:mm time strings.

"},{"location":"v2/frontend/pages/admin/shifts-page/#edit-form-pre-fill","title":"Edit Form Pre-Fill","text":"
const openEdit = (shift: Shift) => {\n  setEditingShift(shift);\n  editForm.setFieldsValue({\n    title: shift.title,\n    description: shift.description,\n    date: dayjs(shift.date),                    // String \u2192 Dayjs object\n    startTime: dayjs(shift.startTime, 'HH:mm'), // HH:mm string \u2192 Dayjs object with format\n    endTime: dayjs(shift.endTime, 'HH:mm'),\n    location: shift.location,\n    maxVolunteers: shift.maxVolunteers,\n    isPublic: shift.isPublic,\n    status: shift.status,\n    cutId: shift.cutId,\n  });\n  setEditDrawerOpen(true);\n};\n

Why dayjs(shift.startTime, 'HH:mm')? TimePicker needs Dayjs object with specific format. Backend stores as \"10:00\" string, convert to Dayjs with HH:mm format.

"},{"location":"v2/frontend/pages/admin/shifts-page/#conditional-status-field","title":"Conditional Status Field","text":"
const shiftFormFields = (isEdit = false) => (\n  <>\n    {/* ... other fields */}\n    <Row gutter={12}>\n      {/* ... maxVolunteers, isPublic */}\n      {isEdit && (\n        <Col xs={12} sm={8}>\n          <Form.Item name=\"status\" label=\"Status\">\n            <Select options={statusOptions} />\n          </Form.Item>\n        </Col>\n      )}\n    </Row>\n  </>\n);\n

Why conditional? Status dropdown only shown in edit mode. Create form defaults to OPEN (set by backend).

"},{"location":"v2/frontend/pages/admin/shifts-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#debounced-search_1","title":"Debounced Search","text":"

Same 300ms debounce pattern as other pages: - Prevents API spam while typing - Only fires after user pauses - Cleanup on unmount

"},{"location":"v2/frontend/pages/admin/shifts-page/#responsive-column-hiding","title":"Responsive Column Hiding","text":"
{ title: 'Time', responsive: ['md'] }       // Hide on < 768px\n{ title: 'Location', responsive: ['lg'] }   // Hide on < 992px\n{ title: 'Area', responsive: ['md'] }       // Hide on < 768px\n

Mobile users see: Title, Date, Volunteers, Status, Public, Actions

"},{"location":"v2/frontend/pages/admin/shifts-page/#usecallback-optimization","title":"useCallback Optimization","text":"
const fetchShifts = useCallback(async (params?: ShiftsListParams) => {\n  // ... fetch logic\n}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]);\n\nconst fetchStats = useCallback(async () => {\n  // ... fetch logic\n}, []);\n\nconst fetchCuts = useCallback(async () => {\n  // ... fetch logic\n}, []);\n

Memoized functions prevent unnecessary re-renders.

"},{"location":"v2/frontend/pages/admin/shifts-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#mobile-576px","title":"Mobile (< 576px)","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#tablet-576px-992px","title":"Tablet (576px - 992px)","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#desktop-992px","title":"Desktop (\u2265 992px)","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#shift-status-not-auto-updating-to-full","title":"Shift Status Not Auto-Updating to FULL","text":"

Problem: Shift reaches max volunteers (8/8), but status stays OPEN instead of changing to FULL.

Diagnosis:

Backend auto-status logic should run on every signup:

if (currentVolunteers >= maxVolunteers) {\n  await prisma.shift.update({\n    where: { id: shiftId },\n    data: { status: 'FULL' },\n  });\n}\n

Common Issues:

  1. Backend logic not running:
  2. Check API logs: docker compose logs api | grep \"shift status\"
  3. Verify signup endpoint includes auto-status update

  4. Race condition:

  5. Multiple signups at same time (public + admin)
  6. Solution: Use Prisma transaction for atomic updates

  7. Status manually set:

  8. Admin changed status to OPEN in edit drawer
  9. Solution: Status field warning: \"Auto-updates to FULL when capacity reached\"

Solution:

Refresh page to see latest status. Backend should auto-update on next signup/removal.

"},{"location":"v2/frontend/pages/admin/shifts-page/#email-all-volunteers-fails","title":"Email All Volunteers Fails","text":"

Problem: Click \"Email All\" button \u2192 Error: \"Failed to email volunteers\"

Diagnosis:

Check SMTP configuration: 1. Navigate to Settings \u2192 Email tab 2. Verify active provider: Production (not MailHog) 3. Click \"Test Connection\" \u2192 Should show success

Common Issues:

  1. MailHog active (dev mode):
  2. Switch to Production provider
  3. Save settings

  4. SMTP credentials invalid:

  5. Test connection fails
  6. Update credentials
  7. Re-test before emailing

  8. No confirmed volunteers:

  9. Email All button disabled if 0 confirmed
  10. Check signups drawer table (only CONFIRMED shown)

Solution:

  1. Fix SMTP settings
  2. Test connection
  3. Retry Email All
"},{"location":"v2/frontend/pages/admin/shifts-page/#volunteer-not-appearing-in-signups","title":"Volunteer Not Appearing in Signups","text":"

Problem: Add volunteer by email \u2192 Success message \u2192 Volunteer not in signups table

Diagnosis:

Check signups drawer filter:

<Table dataSource={signups.filter((s) => s.status === 'CONFIRMED')} />\n

Cancelled signups hidden.

Common Issues:

  1. Volunteer added but immediately cancelled:
  2. Check backend logs for cancellation endpoint calls
  3. Verify signup status in database

  4. Wrong shift:

  5. Added to different shift
  6. Verify shift ID in URL when opening drawer

  7. Duplicate email:

  8. Volunteer already signed up
  9. Backend returns 400: \"User already signed up for this shift\"
  10. Check error message

Solution:

Refresh drawer: Close and re-open signups drawer to fetch latest data.

"},{"location":"v2/frontend/pages/admin/shifts-page/#area-cut-dropdown-empty","title":"Area (Cut) Dropdown Empty","text":"

Problem: Create/edit shift \u2192 Area dropdown shows no options

Diagnosis:

Check cuts API endpoint:

curl http://localhost:4000/api/map/cuts\n

Common Issues:

  1. No cuts created yet:
  2. Navigate to /app/map/cuts
  3. Create at least one cut (polygon boundary)
  4. Return to shifts page

  5. Cuts API failing:

  6. Check API logs: docker compose logs api | grep \"cuts\"
  7. Verify database connection

  8. Cuts fetch not called:

  9. Check browser console for errors
  10. Verify fetchCuts() called in useEffect

Solution:

Create at least one cut in CutsPage before assigning to shifts.

"},{"location":"v2/frontend/pages/admin/shifts-page/#public-shift-not-showing-on-public-page","title":"Public Shift Not Showing on Public Page","text":"

Problem: Set isPublic to true, save shift \u2192 Public /shifts page doesn't show it

Diagnosis:

Check shift criteria for public page: - Status: OPEN or FULL (not CANCELLED or COMPLETED) - isPublic: true - Date: Future (not past)

Common Issues:

  1. Shift date in past:
  2. Past shifts hidden from public page
  3. Edit shift, update date to future

  4. Status CANCELLED:

  5. Cancelled shifts hidden from public page
  6. Change status to OPEN

  7. Browser cache:

  8. Hard refresh public page (Ctrl+Shift+R)

Solution:

Verify all 3 criteria met: OPEN/FULL status, isPublic true, future date.

"},{"location":"v2/frontend/pages/admin/shifts-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/users-page/","title":"UsersPage","text":""},{"location":"v2/frontend/pages/admin/users-page/#overview","title":"Overview","text":"

The UsersPage provides comprehensive user management with full CRUD operations, pagination, search, filtering, and role-based access control. It serves as the primary interface for managing all system users including admins, volunteers, and temporary users.

Route: /app/users Component: admin/src/pages/UsersPage.tsx (400+ lines) Auth Required: Yes (SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN) Layout: AppLayout

"},{"location":"v2/frontend/pages/admin/users-page/#screenshot","title":"Screenshot","text":"

[Screenshot: Users table with columns for Email, Name, Role (color-coded tags), Status (green/red/orange tags), Created At, and Actions. Top bar has search input, role filter dropdown, status filter dropdown, and \"Create User\" button. Each row has Edit and Delete icons. Pagination controls at bottom showing \"20 per page\" and navigation.]

"},{"location":"v2/frontend/pages/admin/users-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/users-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/users-page/#creating-a-user","title":"Creating a User","text":"
  1. Click \"Create User\" button (top right)
  2. Modal opens with form fields:
  3. Email (required)
  4. Name (optional)
  5. Phone (optional)
  6. Password (required, auto-generated option available)
  7. Role (dropdown: Super Admin, Influence Admin, Map Admin, User, Temp)
  8. Status (dropdown: Active, Inactive, Suspended, Expired)
  9. Expiration Date (optional, for TEMP users)
  10. Fill out form fields
  11. Click \"Create\"
  12. Success message: \"User created\"
  13. Modal closes, table refreshes to page 1
"},{"location":"v2/frontend/pages/admin/users-page/#editing-a-user","title":"Editing a User","text":"
  1. Click Edit icon in Actions column
  2. Modal opens with pre-populated form
  3. Modify fields (password optional, leave blank to keep existing)
  4. Click \"Save\"
  5. Success message: \"User updated\"
  6. Modal closes, table data refreshes
"},{"location":"v2/frontend/pages/admin/users-page/#deleting-a-user","title":"Deleting a User","text":"
  1. Click Delete icon in Actions column
  2. Popconfirm appears: \"Are you sure you want to delete this user?\"
  3. Click \"Yes\"
  4. Success message: \"User deleted\"
  5. Table data refreshes
"},{"location":"v2/frontend/pages/admin/users-page/#searching-users","title":"Searching Users","text":"
  1. Type in search input (top left)
  2. Wait 300ms (debounce delay)
  3. Table automatically filters results
  4. Search matches email OR name (case-insensitive)
  5. Resets to page 1 automatically
"},{"location":"v2/frontend/pages/admin/users-page/#filtering-by-rolestatus","title":"Filtering by Role/Status","text":"
  1. Select role from Role Filter dropdown
  2. OR/AND select status from Status Filter dropdown
  3. Table automatically filters results
  4. Filters combine with search (all must match)
  5. Resets to page 1 automatically
"},{"location":"v2/frontend/pages/admin/users-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/users-page/#ant-design-components-used","title":"Ant Design Components Used","text":""},{"location":"v2/frontend/pages/admin/users-page/#table-columns","title":"Table Columns","text":"Column Key Render Sortable Email email Plain text No Name name Plain text No Role role Color-coded Tag No Status status Color-coded Tag No Created At createdAt Formatted date (MMM DD, YYYY) No Actions - Edit + Delete icons No

Column Configuration:

const columns: ColumnsType<User> = [\n  {\n    title: 'Email',\n    dataIndex: 'email',\n    key: 'email',\n  },\n  {\n    title: 'Name',\n    dataIndex: 'name',\n    key: 'name',\n    render: (text: string) => text || '\u2014',\n  },\n  {\n    title: 'Role',\n    dataIndex: 'role',\n    key: 'role',\n    render: (role: UserRole) => (\n      <Tag color={roleColors[role]}>{role.replace('_', ' ')}</Tag>\n    ),\n  },\n  {\n    title: 'Status',\n    dataIndex: 'status',\n    key: 'status',\n    render: (status: UserStatus) => (\n      <Tag color={statusColors[status]}>{status}</Tag>\n    ),\n  },\n  {\n    title: 'Created At',\n    dataIndex: 'createdAt',\n    key: 'createdAt',\n    render: (date: string) => dayjs(date).format('MMM DD, YYYY'),\n  },\n  {\n    title: 'Actions',\n    key: 'actions',\n    render: (_, record: User) => (\n      <Space>\n        <Button\n          type=\"link\"\n          icon={<EditOutlined />}\n          onClick={() => handleEditClick(record)}\n        />\n        <Popconfirm\n          title=\"Are you sure you want to delete this user?\"\n          onConfirm={() => handleDelete(record.id)}\n        >\n          <Button type=\"link\" danger icon={<DeleteOutlined />} />\n        </Popconfirm>\n      </Space>\n    ),\n  },\n];\n
"},{"location":"v2/frontend/pages/admin/users-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/users-page/#local-state","title":"Local State","text":"
const [users, setUsers] = useState<User[]>([]);\nconst [pagination, setPagination] = useState({\n  page: 1,\n  limit: 20,\n  total: 0,\n  totalPages: 0\n});\nconst [loading, setLoading] = useState(false);\nconst [search, setSearch] = useState('');\nconst [debouncedSearch, setDebouncedSearch] = useState('');\nconst [roleFilter, setRoleFilter] = useState<UserRole | undefined>();\nconst [statusFilter, setStatusFilter] = useState<UserStatus | undefined>();\nconst [createModalOpen, setCreateModalOpen] = useState(false);\nconst [editModalOpen, setEditModalOpen] = useState(false);\nconst [editingUser, setEditingUser] = useState<User | null>(null);\nconst [createForm] = Form.useForm();\nconst [editForm] = Form.useForm();\nconst searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\n
"},{"location":"v2/frontend/pages/admin/users-page/#zustand-stores-used","title":"Zustand Stores Used","text":""},{"location":"v2/frontend/pages/admin/users-page/#search-debouncing","title":"Search Debouncing","text":"
const handleSearchChange = (value: string) => {\n  setSearch(value);\n  clearTimeout(searchTimerRef.current);\n  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);\n};\n\nuseEffect(() => {\n  return () => clearTimeout(searchTimerRef.current);\n}, []);\n

Why 300ms?

"},{"location":"v2/frontend/pages/admin/users-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/users-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose GET /api/users List users with pagination/filters POST /api/users Create new user PUT /api/users/:id Update user DELETE /api/users/:id Delete user"},{"location":"v2/frontend/pages/admin/users-page/#fetch-users-with-filters","title":"Fetch Users (with Filters)","text":"
const fetchUsers = useCallback(async (params?: UsersListParams) => {\n  setLoading(true);\n  try {\n    const { data } = await api.get<UsersListResponse>('/users', {\n      params: {\n        page: params?.page ?? pagination.page,\n        limit: params?.limit ?? pagination.limit,\n        search: params?.search ?? (debouncedSearch || undefined),\n        role: params?.role ?? roleFilter,\n        status: params?.status ?? statusFilter,\n      },\n    });\n    setUsers(data.users);\n    setPagination(data.pagination);\n  } catch {\n    message.error('Failed to load users');\n  } finally {\n    setLoading(false);\n  }\n}, [pagination.page, pagination.limit, debouncedSearch, roleFilter, statusFilter]);\n

Response Format:

{\n  \"users\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"email\": \"admin@example.com\",\n      \"name\": \"Admin User\",\n      \"phone\": \"+1234567890\",\n      \"role\": \"SUPER_ADMIN\",\n      \"status\": \"ACTIVE\",\n      \"createdAt\": \"2026-01-15T12:00:00.000Z\",\n      \"updatedAt\": \"2026-02-11T15:30:00.000Z\",\n      \"expiresAt\": null,\n      \"lastLoginAt\": \"2026-02-11T10:00:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 45,\n    \"totalPages\": 3\n  }\n}\n
"},{"location":"v2/frontend/pages/admin/users-page/#create-user","title":"Create User","text":"
const handleCreate = async (values: CreateUserPayload & { expiresAtDate?: dayjs.Dayjs }) => {\n  try {\n    const payload: CreateUserPayload = { ...values };\n    if (values.expiresAtDate) {\n      payload.expiresAt = values.expiresAtDate.toISOString();\n    }\n    delete (payload as unknown as Record<string, unknown>).expiresAtDate;\n    await api.post('/users', payload);\n    message.success('User created');\n    setCreateModalOpen(false);\n    createForm.resetFields();\n    fetchUsers({ page: 1 });\n  } catch (err: unknown) {\n    const msg =\n      (err as { response?: { data?: { error?: { message?: string } } } })\n        ?.response?.data?.error?.message || 'Failed to create user';\n    message.error(msg);\n  }\n};\n

Request Payload:

{\n  \"email\": \"newuser@example.com\",\n  \"name\": \"New User\",\n  \"phone\": \"+1234567890\",\n  \"password\": \"SecurePassword123!\",\n  \"role\": \"USER\",\n  \"status\": \"ACTIVE\",\n  \"expiresAt\": \"2026-03-15T23:59:59.000Z\"\n}\n
"},{"location":"v2/frontend/pages/admin/users-page/#update-user","title":"Update User","text":"
const handleEdit = async (values: UpdateUserPayload & { expiresAtDate?: dayjs.Dayjs | null }) => {\n  if (!editingUser) return;\n  try {\n    const payload: UpdateUserPayload = { ...values };\n    if (values.expiresAtDate) {\n      payload.expiresAt = values.expiresAtDate.toISOString();\n    } else if (values.expiresAtDate === null) {\n      payload.expiresAt = null;\n    }\n    delete (payload as unknown as Record<string, unknown>).expiresAtDate;\n    await api.put(`/users/${editingUser.id}`, payload);\n    message.success('User updated');\n    setEditModalOpen(false);\n    editForm.resetFields();\n    fetchUsers();\n  } catch (err: unknown) {\n    const msg =\n      (err as { response?: { data?: { error?: { message?: string } } } })\n        ?.response?.data?.error?.message || 'Failed to update user';\n    message.error(msg);\n  }\n};\n

Update Payload (Partial):

{\n  \"name\": \"Updated Name\",\n  \"role\": \"MAP_ADMIN\",\n  \"status\": \"ACTIVE\"\n}\n

Note: Password is optional in updates. Leave blank to keep existing password.

"},{"location":"v2/frontend/pages/admin/users-page/#delete-user","title":"Delete User","text":"
const handleDelete = async (id: string) => {\n  try {\n    await api.delete(`/users/${id}`);\n    message.success('User deleted');\n    fetchUsers();\n  } catch {\n    message.error('Failed to delete user');\n  }\n};\n
"},{"location":"v2/frontend/pages/admin/users-page/#form-validation","title":"Form Validation","text":""},{"location":"v2/frontend/pages/admin/users-page/#create-user-form-rules","title":"Create User Form Rules","text":"
<Form.Item\n  name=\"email\"\n  label=\"Email\"\n  rules={[\n    { required: true, message: 'Email is required' },\n    { type: 'email', message: 'Invalid email address' },\n  ]}\n>\n  <Input placeholder=\"user@example.com\" />\n</Form.Item>\n\n<Form.Item\n  name=\"password\"\n  label=\"Password\"\n  rules={[\n    { required: true, message: 'Password is required' },\n    { min: 12, message: 'Password must be at least 12 characters' },\n    {\n      pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)/,\n      message: 'Password must contain uppercase, lowercase, and digit'\n    },\n  ]}\n>\n  <Input.Password placeholder=\"Min 12 chars, uppercase, lowercase, digit\" />\n</Form.Item>\n\n<Form.Item\n  name=\"role\"\n  label=\"Role\"\n  rules={[{ required: true, message: 'Role is required' }]}\n>\n  <Select options={roleOptions} />\n</Form.Item>\n

Password Policy:

"},{"location":"v2/frontend/pages/admin/users-page/#edit-user-form-rules","title":"Edit User Form Rules","text":"

Same as create form, but password is optional:

<Form.Item\n  name=\"password\"\n  label=\"Password\"\n  extra=\"Leave blank to keep existing password\"\n  rules={[\n    { min: 12, message: 'Password must be at least 12 characters' },\n    {\n      pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)/,\n      message: 'Password must contain uppercase, lowercase, and digit'\n    },\n  ]}\n>\n  <Input.Password placeholder=\"Min 12 chars (leave blank to keep existing)\" />\n</Form.Item>\n
"},{"location":"v2/frontend/pages/admin/users-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/users-page/#debounced-search","title":"Debounced Search","text":"

Prevents excessive API calls during typing:

const handleSearchChange = (value: string) => {\n  setSearch(value);\n  clearTimeout(searchTimerRef.current);\n  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);\n};\n

Performance Impact:

"},{"location":"v2/frontend/pages/admin/users-page/#usecallback-for-fetchusers","title":"useCallback for fetchUsers","text":"

Prevents unnecessary re-creation of fetch function:

const fetchUsers = useCallback(async (params?: UsersListParams) => {\n  // ... fetch logic\n}, [pagination.page, pagination.limit, debouncedSearch, roleFilter, statusFilter]);\n

Why useCallback?

"},{"location":"v2/frontend/pages/admin/users-page/#pagination","title":"Pagination","text":"

Server-side pagination reduces memory usage:

"},{"location":"v2/frontend/pages/admin/users-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/users-page/#table-scroll","title":"Table Scroll","text":"

Table uses horizontal scroll on mobile:

<Table\n  columns={columns}\n  dataSource={users}\n  scroll={{ x: 'max-content' }}\n  loading={loading}\n  pagination={{\n    current: pagination.page,\n    pageSize: pagination.limit,\n    total: pagination.total,\n    showSizeChanger: true,\n    showTotal: (total) => `Total ${total} users`,\n  }}\n  onChange={handleTableChange}\n/>\n
"},{"location":"v2/frontend/pages/admin/users-page/#modal-forms","title":"Modal Forms","text":"

Forms use responsive columns:

<Row gutter={16}>\n  <Col xs={24} sm={12}>\n    <Form.Item label=\"Email\" name=\"email\">\n      <Input />\n    </Form.Item>\n  </Col>\n  <Col xs={24} sm={12}>\n    <Form.Item label=\"Name\" name=\"name\">\n      <Input />\n    </Form.Item>\n  </Col>\n</Row>\n

Mobile: Fields stack vertically (xs={24}) Tablet+: Fields display side-by-side (sm={12})

"},{"location":"v2/frontend/pages/admin/users-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/users-page/#search-not-working","title":"Search Not Working","text":"

Problem: Typing in search input doesn't filter results.

Diagnosis:

  1. Check debouncedSearch value in React DevTools
  2. Verify fetchUsers is called after 300ms delay
  3. Check network tab for API call with search param

Solution:

useEffect(() => {\n  fetchUsers({ page: 1 });\n}, [debouncedSearch, roleFilter, statusFilter]);\n

Ensure debouncedSearch is in dependency array, not search.

"},{"location":"v2/frontend/pages/admin/users-page/#failed-to-load-users-error","title":"\"Failed to load users\" Error","text":"

Problem: Table shows error message.

Diagnosis:

Check API response:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/users?page=1&limit=20\"\n

Common Issues:

  1. 401 Unauthorized \u2014 Token expired
  2. 403 Forbidden \u2014 User lacks admin role
  3. 500 Internal Server Error \u2014 Database connection issue
"},{"location":"v2/frontend/pages/admin/users-page/#delete-confirmation-not-appearing","title":"Delete Confirmation Not Appearing","text":"

Problem: Click delete icon but nothing happens.

Diagnosis:

Check Popconfirm component:

<Popconfirm\n  title=\"Are you sure you want to delete this user?\"\n  onConfirm={() => handleDelete(record.id)}\n  okText=\"Yes\"\n  cancelText=\"No\"\n>\n  <Button type=\"link\" danger icon={<DeleteOutlined />} />\n</Popconfirm>\n

Solution:

Ensure Popconfirm wraps the button, not the other way around.

"},{"location":"v2/frontend/pages/admin/users-page/#modal-form-not-resetting","title":"Modal Form Not Resetting","text":"

Problem: Open create modal, enter data, close modal, reopen \u2192 old data still there.

Solution:

Reset form on modal close:

<Modal\n  title=\"Create User\"\n  open={createModalOpen}\n  onCancel={() => {\n    setCreateModalOpen(false);\n    createForm.resetFields();  // Reset form on close\n  }}\n  onOk={() => createForm.submit()}\n>\n  <Form form={createForm} onFinish={handleCreate}>\n    {/* form fields */}\n  </Form>\n</Modal>\n
"},{"location":"v2/frontend/pages/admin/users-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/","title":"WalkSheetPage","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#overview","title":"Overview","text":"

File: admin/src/pages/WalkSheetPage.tsx

Route: /app/walk-sheet

Role Requirements: Any authenticated user (uses authenticate middleware)

Purpose: Generates a printable walk sheet form for canvassing volunteers. The walk sheet is a physical form that volunteers use to record contact information and support levels during door-to-door canvassing. The page fetches customizable settings (title, subtitle, QR codes, footer) and renders a standardized form optimized for printing.

Key Features: - Printable walk sheet with browser print support - Customizable title, subtitle, and footer from MapSettings - Up to 3 configurable QR codes with labels - 12-row contact table with pre-printed columns (Name, Address, Email, Phone, Support, Sign, Notes) - Support level circles (1-4 scale) for quick marking - Sign interest circles (R/L for Right/Left yard placement) - Volunteer name, date, and area/cut fields - Print-optimized styling with CSS @media print rules

Layout: Full AppLayout with Print button in header

Dependencies: - Ant Design v5 (Button, Typography, Spin, App) - react-router-dom (useOutletContext) - QR code generation via /api/qr endpoint

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#1-customizable-header","title":"1. Customizable Header","text":"

Configurable Fields: - Walk Sheet Title: Main heading (e.g., \"Volunteer Canvassing Walk Sheet\") - Walk Sheet Subtitle: Optional subtitle (e.g., \"Ward 5 - Downtown District\")

Source: MapSettings.walkSheetTitle and MapSettings.walkSheetSubtitle

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#2-qr-code-section","title":"2. QR Code Section","text":"

Up to 3 QR Codes: - Each QR code has: - URL: Link to encode in QR code (e.g., campaign page, response wall, shift signup) - Label: Descriptive text below QR code (e.g., \"Report In\", \"Submit Response\", \"Sign Up\") - QR codes displayed horizontally centered - 80\u00d780 pixel size - Generated via /api/qr?text={url}&size=100 endpoint

Source: MapSettings.qrCode1Url/Label, qrCode2Url/Label, qrCode3Url/Label

Example QR Code URLs: - https://cmlite.org/responses/1 - Response submission page - https://cmlite.org/shifts - Shift signup page - https://cmlite.org/campaigns - Campaign listing

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#3-volunteer-information-fields","title":"3. Volunteer Information Fields","text":"

Pre-printed Fields: - Volunteer: Name line (200px underline) - Date: Date line (120px underline) - Area/Cut: Assignment line (120px underline)

Purpose: Volunteer fills these in by hand before starting canvass

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#4-contact-table","title":"4. Contact Table","text":"

12 Rows with 8 Columns:

  1. # (Number): Row number 1-12 (pre-printed)
  2. Name: Blank field for recording contact name
  3. Address / Unit: Blank field for building address and unit number
  4. Email: Blank field for contact email
  5. Phone: Blank field for contact phone number
  6. Support: 4 circles for support level (1-4 scale)
  7. Circle 1 = Strong Support
  8. Circle 2 = Likely Support
  9. Circle 3 = Unsure
  10. Circle 4 = Oppose
  11. Sign: 2 circles for lawn sign interest (R/L)
  12. R = Right side of entrance
  13. L = Left side of entrance
  14. Notes: Blank field for additional notes

Table Styling: - 1px solid borders - 11px font size (print-optimized) - 28px row height (sufficient for handwriting) - Compact padding (4px\u00d76px)

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#5-customizable-footer","title":"5. Customizable Footer","text":"

Footer Text: Optional footer message (e.g., \"Thank you for volunteering!\")

Source: MapSettings.walkSheetFooter

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#6-print-optimization","title":"6. Print Optimization","text":"

CSS @media print Rules: - Hides everything except .walk-sheet-print container - Positions walk sheet at absolute top-left - Reduces font size to 11px for compact printing - Optimizes table borders for clear printing - Hides Print button (no-print class)

Print Trigger: \"Print\" button in page header (calls window.print())

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#configuring-walk-sheet-settings","title":"Configuring Walk Sheet Settings","text":"
  1. Navigate to Map Settings:
  2. Click \"Map\" \u2192 \"Settings\" in sidebar
  3. Scroll to \"Walk Sheet Configuration\" section

  4. Set Walk Sheet Title:

  5. Enter title (e.g., \"Volunteer Canvassing Walk Sheet\")
  6. This appears as main heading on printed sheet

  7. Set Walk Sheet Subtitle (Optional):

  8. Enter subtitle (e.g., \"Ward 5 - Downtown District\")
  9. Appears below title in smaller font

  10. Configure QR Codes (Up to 3):

  11. QR Code 1:
    • URL: Enter full URL to encode (e.g., https://cmlite.org/responses/1)
    • Label: Enter descriptive label (e.g., \"Submit Response\")
  12. QR Code 2: (Optional)
    • URL + Label
  13. QR Code 3: (Optional)
    • URL + Label
  14. QR codes appear centered above contact table

  15. Set Footer Text (Optional):

  16. Enter footer message (e.g., \"Thank you for your time!\")
  17. Appears at bottom of printed sheet

  18. Save Settings:

  19. Click \"Save\" button in Map Settings page
  20. Settings applied to all future walk sheets
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#printing-a-walk-sheet","title":"Printing a Walk Sheet","text":"
  1. Navigate to Walk Sheet:
  2. Click \"Map\" \u2192 \"Walk Sheet\" in sidebar
  3. Page loads with preview of walk sheet

  4. Review Preview:

  5. Check title, subtitle, QR codes
  6. Verify table has 12 rows
  7. Confirm footer text appears

  8. Print Walk Sheet:

  9. Click \"Print\" button in page header
  10. OR press Ctrl+P (Windows/Linux) or Cmd+P (Mac)
  11. Browser print dialog opens

  12. Configure Print Settings:

  13. Orientation: Portrait (recommended)
  14. Paper Size: Letter (8.5\" \u00d7 11\")
  15. Margins: Default or minimal
  16. Background graphics: ON (to print table borders clearly)

  17. Print or Save PDF:

  18. Click \"Print\" to send to printer
  19. OR select \"Save as PDF\" to create digital copy
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#using-walk-sheet-during-canvassing","title":"Using Walk Sheet During Canvassing","text":"
  1. Before Starting:
  2. Print walk sheet (1 per cut/area)
  3. Fill in volunteer name, date, area/cut at top

  4. At Each Door:

  5. Record contact information in next empty row:
    • Name
    • Address / Unit (if multi-unit building)
    • Email (if provided)
    • Phone (if provided)
  6. Circle support level (1-4)
  7. Circle sign interest (R/L) if applicable
  8. Write notes (e.g., \"Call back after 6pm\", \"Not home\")

  9. Completing Walk Sheet:

  10. Fill all 12 rows OR complete area
  11. Return walk sheet to campaign organizer
  12. Organizer enters data into system via Admin GUI

  13. QR Code Usage:

  14. Volunteers can scan QR codes with phone to:
    • Report their location/status
    • Submit response directly to response wall
    • Access campaign resources
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#main-component-structure","title":"Main Component Structure","text":"
export default function WalkSheetPage() {\n  const { setPageHeader } = useOutletContext<AppOutletContext>();\n  const { message } = App.useApp();\n  const [settings, setSettings] = useState<MapSettings | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  // Set page header with Print button\n  useEffect(() => {\n    setPageHeader({\n      title: 'Walk Sheet',\n      actions: (\n        <Button icon={<PrinterOutlined />} onClick={() => window.print()}>\n          Print\n        </Button>\n      ),\n    });\n    return () => setPageHeader(null);\n  }, [setPageHeader]);\n\n  // Load map settings (for walk sheet config)\n  useEffect(() => {\n    api.get('/map/settings')\n      .then(({ data }) => setSettings(data))\n      .catch(() => message.error('Failed to load settings'))\n      .finally(() => setLoading(false));\n  }, []);\n\n  if (loading) {\n    return <Spin size=\"large\" />;\n  }\n\n  // Filter QR codes (only include if URL provided)\n  const qrCodes = [\n    { url: settings?.qrCode1Url, label: settings?.qrCode1Label },\n    { url: settings?.qrCode2Url, label: settings?.qrCode2Label },\n    { url: settings?.qrCode3Url, label: settings?.qrCode3Label },\n  ].filter((q) => q.url);\n\n  // Generate 12 empty rows\n  const rows = Array.from({ length: 12 }, (_, i) => i);\n\n  return (\n    <>\n      <style>{/* Print CSS rules */}</style>\n\n      <div className=\"walk-sheet-print\">\n        {/* Header */}\n        <Title level={3}>{settings?.walkSheetTitle || 'Walk Sheet'}</Title>\n        {settings?.walkSheetSubtitle && <Text>{settings.walkSheetSubtitle}</Text>}\n\n        {/* QR Codes */}\n        {qrCodes.map((qr) => (\n          <img src={`/api/qr?text=${qr.url}&size=100`} alt={qr.label} />\n        ))}\n\n        {/* Volunteer Info */}\n        <div>\n          <Text strong>Volunteer: </Text><span className=\"underline\" />\n          <Text strong>Date: </Text><span className=\"underline\" />\n          <Text strong>Area/Cut: </Text><span className=\"underline\" />\n        </div>\n\n        {/* Contact Table */}\n        <table>\n          <thead>\n            <tr>\n              <th>#</th>\n              <th>Name</th>\n              <th>Address / Unit</th>\n              <th>Email</th>\n              <th>Phone</th>\n              <th>Support</th>\n              <th>Sign</th>\n              <th>Notes</th>\n            </tr>\n          </thead>\n          <tbody>\n            {rows.map((i) => (\n              <tr key={i}>\n                <td>{i + 1}</td>\n                <td>&nbsp;</td> {/* Blank cells for handwriting */}\n                {/* ... more blank cells ... */}\n                <td> {/* Support circles */}\n                  <span className=\"support-circle\">1</span>\n                  <span className=\"support-circle\">2</span>\n                  <span className=\"support-circle\">3</span>\n                  <span className=\"support-circle\">4</span>\n                </td>\n                <td> {/* Sign circles */}\n                  <span className=\"support-circle\">R</span>\n                  <span className=\"support-circle\">L</span>\n                </td>\n                <td>&nbsp;</td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n\n        {/* Footer */}\n        {settings?.walkSheetFooter && <Text>{settings.walkSheetFooter}</Text>}\n      </div>\n    </>\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  1. Button - Print button in page header
  2. Typography.Title - Walk sheet main heading
  3. Typography.Text - Subtitle, labels, footer text
  4. Spin - Loading indicator while settings fetch
  5. App.useApp() - Toast message for errors
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#print-css-styling","title":"Print CSS Styling","text":"
<style>{`\n  @media print {\n    /* Hide everything except walk sheet */\n    body * { visibility: hidden; }\n    .walk-sheet-print, .walk-sheet-print * { visibility: visible; }\n\n    /* Position walk sheet at top-left */\n    .walk-sheet-print {\n      position: absolute;\n      left: 0;\n      top: 0;\n      width: 100%;\n      font-size: 11px;\n    }\n\n    /* Hide Print button */\n    .walk-sheet-print .no-print { display: none !important; }\n  }\n\n  /* Table styling (screen + print) */\n  .walk-sheet-print table {\n    width: 100%;\n    border-collapse: collapse;\n  }\n\n  .walk-sheet-print th,\n  .walk-sheet-print td {\n    border: 1px solid #555;\n    padding: 4px 6px;\n    text-align: left;\n    font-size: 11px;\n  }\n\n  .walk-sheet-print th {\n    background: rgba(255,255,255,0.05);\n    font-weight: 600;\n  }\n\n  /* Support level circles */\n  .support-circle {\n    display: inline-block;\n    width: 16px;\n    height: 16px;\n    border: 1.5px solid rgba(255,255,255,0.4);\n    border-radius: 50%;\n    text-align: center;\n    line-height: 14px;\n    font-size: 9px;\n    margin-right: 2px;\n  }\n`}</style>\n

Key Print Rules: - visibility: hidden on all elements except .walk-sheet-print - Absolute positioning at top-left (0, 0) - 11px base font size (compact, readable when printed) - Solid borders on table cells for clear gridlines - Support circles rendered as border-only circles (volunteer fills in by hand)

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#local-component-state-usestate","title":"Local Component State (useState)","text":"

No Zustand stores used - All state managed locally with React hooks.

// Map settings state (loaded from API)\nconst [settings, setSettings] = useState<MapSettings | null>(null);\n\n// Loading state\nconst [loading, setLoading] = useState(true);\n
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#state-flow","title":"State Flow","text":"
  1. Component Mounts:
  2. loadSettings() called in useEffect
  3. Fetches map settings via GET /api/map/settings
  4. Sets settings state
  5. Sets loading to false

  6. Settings Loaded:

  7. Extracts walk sheet configuration:
    • walkSheetTitle, walkSheetSubtitle, walkSheetFooter
    • qrCode1Url/Label, qrCode2Url/Label, qrCode3Url/Label
  8. Filters QR codes (only include if URL provided)
  9. Renders walk sheet with settings

  10. User Clicks Print:

  11. window.print() called
  12. Browser opens print dialog
  13. Print CSS rules activate
  14. Walk sheet rendered in print layout
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/map/settings - Fetch map settings (including walk sheet config)
  2. GET /api/qr - Generate QR code PNG (public endpoint, no auth)
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#api-client","title":"API Client","text":"
import { api } from '@/lib/api';\n\n// All authenticated requests use API client with automatic token refresh\n
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#example-api-calls","title":"Example API Calls","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#1-load-map-settings","title":"1. Load Map Settings","text":"
useEffect(() => {\n  api.get('/map/settings')\n    .then(({ data }) => {\n      setSettings(data);\n    })\n    .catch(() => {\n      message.error('Failed to load settings');\n    })\n    .finally(() => {\n      setLoading(false);\n    });\n}, []);\n

Response Format:

{\n  \"id\": 1,\n  \"walkSheetTitle\": \"Volunteer Canvassing Walk Sheet\",\n  \"walkSheetSubtitle\": \"Ward 5 - Downtown District\",\n  \"walkSheetFooter\": \"Thank you for volunteering!\",\n  \"qrCode1Url\": \"https://cmlite.org/responses/1\",\n  \"qrCode1Label\": \"Submit Response\",\n  \"qrCode2Url\": \"https://cmlite.org/shifts\",\n  \"qrCode2Label\": \"Sign Up for Shift\",\n  \"qrCode3Url\": null,\n  \"qrCode3Label\": null,\n  \"centerLat\": 45.5017,\n  \"centerLng\": -73.5673,\n  \"defaultZoom\": 13,\n  \"updatedAt\": \"2025-02-11T10:00:00Z\"\n}\n

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#2-generate-qr-code-embedded-in-img-src","title":"2. Generate QR Code (Embedded in img src)","text":"
const API_BASE = import.meta.env.VITE_API_URL || '';\n\n<img\n  src={`${API_BASE}/api/qr?text=${encodeURIComponent(qr.url)}&size=100`}\n  alt={qr.label || 'QR'}\n  style={{ width: 80, height: 80 }}\n/>\n

Endpoint: GET /api/qr?text={url}&size={pixels}

Query Parameters: - text (required): URL or text to encode in QR code - size (optional): QR code pixel size (default: 200)

Response: PNG image (binary data)

Example URL:

http://api.cmlite.org/api/qr?text=https%3A%2F%2Fcmlite.org%2Fresponses%2F1&size=100\n

Note: This endpoint is public (no authentication required) to allow QR codes to be scanned by anyone.

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#complete-component-with-print-button","title":"Complete Component with Print Button","text":"
import { useEffect, useState } from 'react';\nimport { Button, Typography, Spin, App } from 'antd';\nimport { PrinterOutlined } from '@ant-design/icons';\nimport { useOutletContext } from 'react-router-dom';\nimport { api } from '@/lib/api';\nimport type { AppOutletContext } from '@/components/AppLayout';\nimport type { MapSettings } from '@/types/api';\n\nconst { Title, Text } = Typography;\nconst API_BASE = import.meta.env.VITE_API_URL || '';\n\nexport default function WalkSheetPage() {\n  const { setPageHeader } = useOutletContext<AppOutletContext>();\n  const { message } = App.useApp();\n  const [settings, setSettings] = useState<MapSettings | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  // Set page header with Print button\n  useEffect(() => {\n    setPageHeader({\n      title: 'Walk Sheet',\n      actions: (\n        <Button icon={<PrinterOutlined />} onClick={() => window.print()}>\n          Print\n        </Button>\n      ),\n    });\n    return () => setPageHeader(null);\n  }, [setPageHeader]);\n\n  // Load settings\n  useEffect(() => {\n    api.get('/map/settings')\n      .then(({ data }) => setSettings(data))\n      .catch(() => message.error('Failed to load settings'))\n      .finally(() => setLoading(false));\n  }, [message]);\n\n  if (loading) {\n    return <div style={{ textAlign: 'center', padding: 48 }}><Spin size=\"large\" /></div>;\n  }\n\n  // Filter QR codes (only show if URL provided)\n  const qrCodes = [\n    { url: settings?.qrCode1Url, label: settings?.qrCode1Label },\n    { url: settings?.qrCode2Url, label: settings?.qrCode2Label },\n    { url: settings?.qrCode3Url, label: settings?.qrCode3Label },\n  ].filter((q) => q.url);\n\n  // Generate 12 empty rows\n  const rows = Array.from({ length: 12 }, (_, i) => i);\n\n  return (\n    <>\n      <style>{`\n        @media print {\n          body * { visibility: hidden; }\n          .walk-sheet-print, .walk-sheet-print * { visibility: visible; }\n          .walk-sheet-print {\n            position: absolute;\n            left: 0;\n            top: 0;\n            width: 100%;\n            font-size: 11px;\n          }\n          .walk-sheet-print .no-print { display: none !important; }\n        }\n        .walk-sheet-print table {\n          width: 100%;\n          border-collapse: collapse;\n        }\n        .walk-sheet-print th,\n        .walk-sheet-print td {\n          border: 1px solid #555;\n          padding: 4px 6px;\n          text-align: left;\n          font-size: 11px;\n        }\n        .walk-sheet-print th {\n          background: rgba(255,255,255,0.05);\n          font-weight: 600;\n        }\n        .support-circle {\n          display: inline-block;\n          width: 16px;\n          height: 16px;\n          border: 1.5px solid rgba(255,255,255,0.4);\n          border-radius: 50%;\n          text-align: center;\n          line-height: 14px;\n          font-size: 9px;\n          margin-right: 2px;\n        }\n      `}</style>\n\n      <div className=\"walk-sheet-print\">\n        {/* Header */}\n        <div style={{ textAlign: 'center', marginBottom: 16 }}>\n          <Title level={3} style={{ marginBottom: 2 }}>\n            {settings?.walkSheetTitle || 'Walk Sheet'}\n          </Title>\n          {settings?.walkSheetSubtitle && (\n            <Text type=\"secondary\" style={{ fontSize: 14 }}>{settings.walkSheetSubtitle}</Text>\n          )}\n        </div>\n\n        {/* QR Codes */}\n        {qrCodes.length > 0 && (\n          <div style={{ display: 'flex', justifyContent: 'center', gap: 24, marginBottom: 16 }}>\n            {qrCodes.map((qr, idx) => (\n              <div key={idx} style={{ textAlign: 'center' }}>\n                <img\n                  src={`${API_BASE}/api/qr?text=${encodeURIComponent(qr.url!)}&size=100`}\n                  alt={qr.label || 'QR'}\n                  style={{ width: 80, height: 80 }}\n                />\n                {qr.label && (\n                  <div style={{ fontSize: 10, marginTop: 2 }}>\n                    <Text type=\"secondary\">{qr.label}</Text>\n                  </div>\n                )}\n              </div>\n            ))}\n          </div>\n        )}\n\n        {/* Volunteer info line */}\n        <div style={{ display: 'flex', gap: 16, marginBottom: 12 }}>\n          <div style={{ flex: 1 }}>\n            <Text strong>Volunteer: </Text>\n            <span style={{ borderBottom: '1px solid rgba(255,255,255,0.3)', display: 'inline-block', width: 200 }}>&nbsp;</span>\n          </div>\n          <div>\n            <Text strong>Date: </Text>\n            <span style={{ borderBottom: '1px solid rgba(255,255,255,0.3)', display: 'inline-block', width: 120 }}>&nbsp;</span>\n          </div>\n          <div>\n            <Text strong>Area/Cut: </Text>\n            <span style={{ borderBottom: '1px solid rgba(255,255,255,0.3)', display: 'inline-block', width: 120 }}>&nbsp;</span>\n          </div>\n        </div>\n\n        {/* Contact table */}\n        <table>\n          <thead>\n            <tr>\n              <th style={{ width: 20 }}>#</th>\n              <th>Name</th>\n              <th>Address / Unit</th>\n              <th>Email</th>\n              <th>Phone</th>\n              <th style={{ width: 70 }}>Support</th>\n              <th style={{ width: 50 }}>Sign</th>\n              <th>Notes</th>\n            </tr>\n          </thead>\n          <tbody>\n            {rows.map((i) => (\n              <tr key={i}>\n                <td style={{ textAlign: 'center' }}>{i + 1}</td>\n                <td style={{ height: 28 }}>&nbsp;</td>\n                <td>&nbsp;</td>\n                <td>&nbsp;</td>\n                <td>&nbsp;</td>\n                <td style={{ textAlign: 'center' }}>\n                  <span className=\"support-circle\">1</span>\n                  <span className=\"support-circle\">2</span>\n                  <span className=\"support-circle\">3</span>\n                  <span className=\"support-circle\">4</span>\n                </td>\n                <td style={{ textAlign: 'center' }}>\n                  <span className=\"support-circle\">R</span>\n                  <span className=\"support-circle\">L</span>\n                </td>\n                <td>&nbsp;</td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n\n        {/* Footer */}\n        {settings?.walkSheetFooter && (\n          <div style={{ marginTop: 16, fontSize: 11, textAlign: 'center' }}>\n            <Text type=\"secondary\">{settings.walkSheetFooter}</Text>\n          </div>\n        )}\n      </div>\n    </>\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#qr-code-generation-pattern","title":"QR Code Generation Pattern","text":"
// 1. In component\nconst API_BASE = import.meta.env.VITE_API_URL || '';\n\n// 2. In JSX\n<img\n  src={`${API_BASE}/api/qr?text=${encodeURIComponent(url)}&size=${size}`}\n  alt=\"QR Code\"\n  style={{ width: size, height: size }}\n/>\n\n// Examples:\n// Campaign response page\nconst url1 = 'https://cmlite.org/responses/1';\n<img src={`${API_BASE}/api/qr?text=${encodeURIComponent(url1)}&size=100`} />\n\n// Shift signup page\nconst url2 = 'https://cmlite.org/shifts';\n<img src={`${API_BASE}/api/qr?text=${encodeURIComponent(url2)}&size=150`} />\n

Important: Always use encodeURIComponent() to escape special characters in URL.

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#print-trigger-pattern","title":"Print Trigger Pattern","text":"
// 1. Add Print button to page header\nuseEffect(() => {\n  setPageHeader({\n    title: 'Walk Sheet',\n    actions: (\n      <Button icon={<PrinterOutlined />} onClick={() => window.print()}>\n        Print\n      </Button>\n    ),\n  });\n  return () => setPageHeader(null);\n}, [setPageHeader]);\n\n// 2. Add print CSS rules\n<style>{`\n  @media print {\n    body * { visibility: hidden; }\n    .printable-content, .printable-content * { visibility: visible; }\n    .printable-content {\n      position: absolute;\n      left: 0;\n      top: 0;\n      width: 100%;\n    }\n  }\n`}</style>\n\n// 3. Wrap content in printable class\n<div className=\"printable-content\">\n  {/* Content to print */}\n</div>\n
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#1-single-settings-fetch","title":"1. Single Settings Fetch","text":"

Settings loaded once on mount:

useEffect(() => {\n  api.get('/map/settings')\n    .then(({ data }) => setSettings(data))\n    .catch(() => message.error('Failed to load settings'))\n    .finally(() => setLoading(false));\n}, []); // Empty dependency array = run once\n

Benefit: Minimizes API requests. Settings cached in state until page unmount.

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#2-inline-qr-code-images","title":"2. Inline QR Code Images","text":"

QR codes embedded as <img> tags with src pointing to QR API:

<img src={`${API_BASE}/api/qr?text=${url}&size=100`} />\n

Benefit: Browser caches QR code images. No JavaScript overhead for QR generation.

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#3-static-row-generation","title":"3. Static Row Generation","text":"

12 rows generated once with Array.from():

const rows = Array.from({ length: 12 }, (_, i) => i);\n\n{rows.map((i) => <tr key={i}>...</tr>)}\n

Benefit: Simple, performant array mapping. No state updates or re-renders.

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#4-print-css-optimization","title":"4. Print CSS Optimization","text":"

Print rules use visibility: hidden instead of display: none:

body * { visibility: hidden; }\n.walk-sheet-print, .walk-sheet-print * { visibility: visible; }\n

Benefit: Preserves layout and spacing. Prevents reflow during print preparation.

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#no-mobile-responsiveness-needed","title":"No Mobile Responsiveness Needed","text":"

Walk sheet is print-only - not designed for mobile viewing: - Page intended for desktop browsers with print capability - Print layout fixed at Letter size (8.5\" \u00d7 11\") - No mobile-specific styling or breakpoints

Rationale: Physical walk sheets used by volunteers in field, printed from desktop computers before canvassing.

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#print-layout","title":"Print Layout","text":"
@media print {\n  .walk-sheet-print {\n    position: absolute;\n    left: 0;\n    top: 0;\n    width: 100%;\n    font-size: 11px;\n  }\n\n  @page {\n    size: letter portrait;\n    margin: 0.5in;\n  }\n}\n

Fixed Layout: - Letter size paper (8.5\" \u00d7 11\") - Portrait orientation - 0.5\" margins all sides - 11px base font size (fits 12 rows + header/footer on one page)

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#print-only-page","title":"Print-Only Page","text":"

Walk sheet is physical form, not interactive UI. Accessibility considerations minimal:

  1. Semantic HTML:
  2. <table> for contact grid
  3. <th> for column headers
  4. <td> for data cells

  5. Print Button:

  6. Keyboard accessible (Tab + Enter)
  7. Icon + text label (\"Print\")
  8. ARIA label implicit from button text

  9. Screen Reader Support:

  10. Table headers announced for each column
  11. Row numbers read in sequence
  12. QR code alt attributes describe purpose

Note: Once printed, walk sheet relies on visual cues (circles, lines, table borders) for volunteers to fill in by hand.

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#problem-qr-codes-not-appearing","title":"Problem: QR Codes Not Appearing","text":"

Symptoms: - Walk sheet loads but QR code section is empty - Expected 1-3 QR codes but none visible

Causes: 1. No QR code URLs configured in Map Settings 2. QR API endpoint not responding 3. CORS issues blocking QR images

Solutions:

  1. Check Map Settings:
  2. Navigate to \"Map\" \u2192 \"Settings\"
  3. Scroll to \"Walk Sheet Configuration\"
  4. Verify QR Code URLs filled in:
    • qrCode1Url, qrCode2Url, qrCode3Url
  5. At least one URL must be provided

  6. Test QR API endpoint:

    curl http://localhost:4000/api/qr?text=https://example.com&size=100 --output test-qr.png\n

  7. Should return PNG image
  8. Open test-qr.png to verify QR code generated

  9. Check browser console:

  10. Open DevTools (F12)
  11. Go to Network tab
  12. Refresh walk sheet page
  13. Look for /api/qr?text=... requests
  14. Check status codes (should be 200)
  15. If 404, QR API route not registered
  16. If CORS error, check nginx CORS headers

  17. Verify API base URL:

  18. Check .env file: VITE_API_URL=http://localhost:4000
  19. Restart admin dev server after changing .env
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#problem-walk-sheet-doesnt-print-correctly","title":"Problem: Walk Sheet Doesn't Print Correctly","text":"

Symptoms: - Print preview shows blank page - Print preview shows partial content - Table borders not visible when printed

Causes: 1. Browser print settings incorrect 2. Background graphics disabled 3. Print CSS not applying 4. Page margins too large

Solutions:

  1. Enable background graphics:
  2. In print dialog, check \"Background graphics\" option
  3. This ensures table borders and support circles print

  4. Adjust page margins:

  5. In print dialog, set margins to \"Default\" or \"Minimal\"
  6. Too large margins can cut off content

  7. Verify print CSS:

  8. View print preview (Ctrl+P or Cmd+P)
  9. Check that only walk sheet visible (no sidebar, no header)
  10. If other elements visible, print CSS not applying

  11. Check browser zoom:

  12. Reset zoom to 100% (Ctrl+0 or Cmd+0)
  13. Print preview at wrong zoom can cause layout issues

  14. Try different browser:

  15. Chrome, Firefox, and Edge have different print engines
  16. If one fails, try another
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#problem-support-circles-too-small-when-printed","title":"Problem: Support Circles Too Small When Printed","text":"

Symptoms: - Support level circles (1-4) and sign circles (R/L) are too small to fill in by hand - Volunteers complain circles hard to mark

Causes: 1. Print scaling set to \"Fit to page\" (shrinks content) 2. Circle size optimized for screen, not print

Solutions:

  1. Check print scaling:
  2. In print dialog, set scale to \"100%\" (not \"Fit to page\")
  3. \"Fit to page\" shrinks content to fit, making circles smaller

  4. Adjust circle size in code:

  5. Edit WalkSheetPage.tsx
  6. Increase .support-circle dimensions:
    .support-circle {\n  width: 20px;  /* Was 16px */\n  height: 20px; /* Was 16px */\n  font-size: 11px; /* Was 9px */\n}\n
  7. Save and refresh page
  8. Print again to test

  9. Increase row height:

  10. More vertical space gives volunteers more room to mark:
    <td style={{ height: 32 }}>&nbsp;</td> {/* Was 28px */}\n
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#problem-footer-text-cut-off","title":"Problem: Footer Text Cut Off","text":"

Symptoms: - Footer message not visible in print preview - Footer appears on screen but missing when printed

Causes: 1. Page margins cutting off bottom content 2. Footer outside printable area 3. Content too tall for one page

Causes: 1. Footer outside printable area due to margins 2. Content too tall to fit on one page

Solutions:

  1. Reduce page margins:
  2. In print dialog, set margins to \"Minimal\" (0.25\")
  3. This gives more vertical space for content

  4. Reduce font sizes:

  5. Edit print CSS:
    @media print {\n  .walk-sheet-print { font-size: 10px !important; } /* Was 11px */\n  .walk-sheet-print table { font-size: 8px !important; } /* Was 9px */\n}\n
  6. Smaller fonts = more content fits on page

  7. Reduce number of rows:

  8. If footer consistently cut off, reduce rows from 12 to 10:

    const rows = Array.from({ length: 10 }, (_, i) => i); // Was 12\n

  9. Remove footer (temporary):

  10. If footer not essential, remove from Map Settings:
    • Navigate to \"Map\" \u2192 \"Settings\"
    • Clear \"Walk Sheet Footer\" field
    • Save settings
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#problem-titlesubtitle-not-appearing","title":"Problem: Title/Subtitle Not Appearing","text":"

Symptoms: - Walk sheet header shows default \"Walk Sheet\" instead of custom title - Subtitle missing entirely

Causes: 1. Map Settings not saved correctly 2. API not returning settings 3. Settings state null/undefined

Solutions:

  1. Verify Map Settings saved:
  2. Navigate to \"Map\" \u2192 \"Settings\"
  3. Check \"Walk Sheet Title\" and \"Walk Sheet Subtitle\" fields
  4. Re-enter values if blank
  5. Click \"Save\" button
  6. Success message should appear

  7. Check browser console:

  8. Open DevTools (F12)
  9. Go to Console tab
  10. Look for error messages
  11. If \"Failed to load settings\", API request failed

  12. Check Network tab:

  13. Open DevTools (F12)
  14. Go to Network tab
  15. Refresh walk sheet page
  16. Look for GET /api/map/settings request
  17. Check Response tab for settings data:
    {\n  \"walkSheetTitle\": \"...\",\n  \"walkSheetSubtitle\": \"...\"\n}\n
  18. If fields null/missing, settings not saved in database

  19. Check database:

    docker compose exec api npx prisma studio\n# Navigate to MapSettings table\n# Verify walkSheetTitle and walkSheetSubtitle columns populated\n

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#backend-documentation","title":"Backend Documentation","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#frontend-documentation","title":"Frontend Documentation","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#feature-documentation","title":"Feature Documentation","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#api-documentation","title":"API Documentation","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#user-guides","title":"User Guides","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#deployment-documentation","title":"Deployment Documentation","text":""},{"location":"v2/frontend/pages/public/","title":"Public Pages","text":"

Public pages provide the public-facing interface for campaign supporters and volunteers. These pages are accessible without authentication and use a dark theme for visual consistency.

"},{"location":"v2/frontend/pages/public/#route-context","title":"Route Context","text":""},{"location":"v2/frontend/pages/public/#campaign-pages","title":"Campaign Pages","text":""},{"location":"v2/frontend/pages/public/#campaigns-list-page","title":"Campaigns List Page","text":"

Route: /campaigns

Featured campaign listing:

Features: - Public campaign discovery - Featured campaigns first - Card-based design - Mobile responsive

"},{"location":"v2/frontend/pages/public/#campaign-page","title":"Campaign Page","text":"

Route: /campaigns/:id

Campaign detail and action page:

Features: - Postal code \u2192 representative lookup - Email to representatives - Form validation - Success confirmation - Response submission link

"},{"location":"v2/frontend/pages/public/#response-wall-page","title":"Response Wall Page","text":"

Route: /responses/:campaignId

Public response submissions and viewing:

Features: - Submit responses anonymously - Email verification required - Upvote responses - Sort by newest/popular - Responsive cards

"},{"location":"v2/frontend/pages/public/#map-location-pages","title":"Map & Location Pages","text":""},{"location":"v2/frontend/pages/public/#map-page","title":"Map Page","text":"

Route: /map

Public interactive map:

Features: - OpenStreetMap tiles - Custom markers - Polygon overlays - Popup information - Mobile responsive

"},{"location":"v2/frontend/pages/public/#shifts-page","title":"Shifts Page","text":"

Route: /shifts

Public shift signup:

Features: - Filter by date/cut - Quick signup flow - Anonymous signups (creates TEMP user) - Email notifications - Mobile responsive

"},{"location":"v2/frontend/pages/public/#content-pages","title":"Content Pages","text":""},{"location":"v2/frontend/pages/public/#landing-page","title":"Landing Page","text":"

Route: /p/:slug

Rendered landing pages:

Features: - Dynamic content from database - Custom styling - Block-based layout - Published pages only

"},{"location":"v2/frontend/pages/public/#media-pages","title":"Media Pages","text":""},{"location":"v2/frontend/pages/public/#media-gallery-page","title":"Media Gallery Page","text":"

Route: /media

Public video gallery:

Features: - Public videos only (unlocked + shared) - Responsive grid - Click to view details - Emoji reactions - Mobile responsive

"},{"location":"v2/frontend/pages/public/#media-viewer-page","title":"Media Viewer Page","text":"

Route: /media/:id

Video detail page:

Features: - HTML5 video player - Reaction tracking - Social sharing - Mobile responsive

"},{"location":"v2/frontend/pages/public/#public-page-count","title":"Public Page Count","text":"

Total: 8 public pages

"},{"location":"v2/frontend/pages/public/#common-features","title":"Common Features","text":"

Public pages share:

"},{"location":"v2/frontend/pages/public/#theme-colors","title":"Theme Colors","text":"
colorBgBase: '#0d1b2a'       // Dark navy background\ncolorBgContainer: '#1b2838'  // Container background\ncolorPrimary: '#3498db'      // Bright blue accent\ncolorLink: '#3498db'         // Link color\ncolorText: '#e0e0e0'         // Light text\ncolorTextSecondary: '#a0a0a0' // Secondary text\n
"},{"location":"v2/frontend/pages/public/#layout-structure","title":"Layout Structure","text":"

Public pages use PublicLayout which provides:

"},{"location":"v2/frontend/pages/public/#mobile-responsiveness","title":"Mobile Responsiveness","text":"

Public pages are optimized for mobile:

"},{"location":"v2/frontend/pages/public/#api-integration","title":"API Integration","text":"

Public pages use direct axios (no auth interceptor):

import axios from 'axios';\n\nconst response = await axios.get(\n  `${import.meta.env.VITE_API_URL}/api/campaigns/public`\n);\n

Admin pages use authenticated api client from lib/api.ts.

"},{"location":"v2/frontend/pages/public/#form-validation","title":"Form Validation","text":"

Public forms use Zod validation:

const emailSchema = z.object({\n  email: z.string().email(),\n  message: z.string().min(10),\n});\n
"},{"location":"v2/frontend/pages/public/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/public/campaign-page/","title":"Campaign Detail Page","text":""},{"location":"v2/frontend/pages/public/campaign-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/public/CampaignPage.tsx (613 lines)

Route: /campaigns/:id

Role Requirements: Public access (no authentication required)

Purpose: Individual campaign detail page providing a complete advocacy workflow from representative lookup through email sending, with optional response wall integration and social sharing capabilities.

Key Features:

Layout: Uses PublicLayout component with dark theme

"},{"location":"v2/frontend/pages/public/campaign-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/campaign-page/#1-step-based-workflow","title":"1. Step-Based Workflow","text":"

Three-step process guides users through advocacy action:

Step Indicator: - Ant Design Steps component - Clickable step headers for navigation - Current step highlighted in blue - Completed steps marked with checkmark - Mobile: Switches to vertical orientation

Navigation Controls: - \"Previous\" button (disabled on step 1) - \"Next\" button (changes to \"Send Emails\" on step 3) - \"Back to Campaigns\" link in header

"},{"location":"v2/frontend/pages/public/campaign-page/#2-hero-section","title":"2. Hero Section","text":"

Prominent campaign header with visual branding:

"},{"location":"v2/frontend/pages/public/campaign-page/#3-representative-lookup","title":"3. Representative Lookup","text":"

Government-level aware representative discovery:

"},{"location":"v2/frontend/pages/public/campaign-page/#4-email-sending-system","title":"4. Email Sending System","text":"

Dual-mode email delivery with tracking:

SMTP Send (Tracked): - Sends via backend BullMQ queue - Tracked in CampaignEmail table - Statistics reflected in dashboard - Requires valid email address - Shows success confirmation - Increments \"Emails Sent\" counter

Email App (Mailto): - Opens user's default email client - Pre-populates to, subject, body fields - Not tracked in system - Works offline - Better for complex email setups (signatures, attachments) - No backend dependency

Email Preview: - Live rendering of email template - Substitutes {name}, {email}, {postalCode} placeholders - Shows subject line - Read-only by default - Optional editing mode (if allowEmailEditing=true)

"},{"location":"v2/frontend/pages/public/campaign-page/#5-response-wall-integration","title":"5. Response Wall Integration","text":"

Campaign-specific response display:

"},{"location":"v2/frontend/pages/public/campaign-page/#6-social-sharing","title":"6. Social Sharing","text":"

ShareButtons component for campaign promotion:

"},{"location":"v2/frontend/pages/public/campaign-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/public/campaign-page/#complete-advocacy-flow","title":"Complete Advocacy Flow","text":"
  1. User arrives at campaign page (via /campaigns/:id)
  2. Step 1 loads automatically showing campaign info
  3. User reads description and decides to take action
  4. User clicks \"Next\" to proceed to Step 2
  5. User enters postal code in \"Your Representatives\" section
  6. API lookup triggered on blur or Enter key
  7. Representatives filtered by government level
  8. Auto-advance to Step 3 when reps loaded
  9. User reviews email preview with personalized content
  10. User edits email (if allowed by campaign settings)
  11. User clicks \"Send\" button on rep card (SMTP option)
    • OR clicks \"Open in Email App\" (mailto option)
  12. Backend creates CampaignEmail record and queues job
  13. Success message displays confirming email sent
  14. User repeats for additional representatives
  15. User views response wall (optional) to see others' activity
  16. User shares campaign on social media
"},{"location":"v2/frontend/pages/public/campaign-page/#representative-selection-flow","title":"Representative Selection Flow","text":"

Representative selection happens implicitly (no checkboxes):

  1. User clicks \"Send\" on specific rep card
  2. Email sent to that rep only
  3. User can send to multiple reps by clicking multiple cards
  4. Each send creates separate CampaignEmail record
  5. No bulk sending (encourages personalization)
"},{"location":"v2/frontend/pages/public/campaign-page/#error-recovery-flow","title":"Error Recovery Flow","text":"

Invalid Postal Code: 1. User enters malformed postal code 2. API returns 404 or empty array 3. Message displays: \"No representatives found\" 4. User corrects postal code 5. Re-triggers lookup

Email Send Failure: 1. User clicks Send button 2. API returns 500 error 3. Error message displays 4. Send button remains enabled 5. User can retry immediately

Missing Information: 1. User tries to send without entering email 2. Form validation triggers 3. Required field highlighted in red 4. User fills in email 5. Proceeds with send

"},{"location":"v2/frontend/pages/public/campaign-page/#component-structure","title":"Component Structure","text":"
import React, { useState, useEffect } from 'react';\nimport { useParams, Link } from 'react-router-dom';\nimport {\n  Steps,\n  Button,\n  Input,\n  Card,\n  Row,\n  Col,\n  Typography,\n  Form,\n  message,\n  Spin,\n  Tag,\n  Grid,\n  Space\n} from 'antd';\nimport {\n  MailOutlined,\n  SearchOutlined,\n  CommentOutlined,\n  ArrowLeftOutlined,\n  SendOutlined,\n  DesktopOutlined\n} from '@ant-design/icons';\nimport PublicLayout from '../../components/PublicLayout';\nimport ShareButtons from '../../components/ShareButtons';\nimport axios from 'axios';\n\nconst { Title, Paragraph, Text } = Typography;\nconst { Step } = Steps;\nconst { TextArea } = Input;\nconst { useBreakpoint } = Grid;\n\ninterface Campaign {\n  id: string;\n  title: string;\n  description: string | null;\n  slug: string;\n  coverPhoto: string | null;\n  governmentLevel: string[];\n  targetType: string;\n  emailSubject: string;\n  emailBody: string;\n  allowEmailEditing: boolean;\n  isActive: boolean;\n  emailsSentCount: number;\n  responsesCount: number;\n}\n\ninterface Representative {\n  name: string;\n  district_name: string;\n  elected_office: string;\n  party_name: string;\n  email: string;\n  photo_url: string;\n  government_level: string;\n  offices: Array<{\n    tel: string;\n    type: string;\n    postal: string;\n  }>;\n}\n\nconst CampaignPage: React.FC = () => {\n  const { id } = useParams<{ id: string }>();\n  const [currentStep, setCurrentStep] = useState(0);\n  const [campaign, setCampaign] = useState<Campaign | null>(null);\n  const [loading, setLoading] = useState(true);\n  const [postalCode, setPostalCode] = useState('');\n  const [representatives, setRepresentatives] = useState<Representative[]>([]);\n  const [repsLoading, setRepsLoading] = useState(false);\n  const [userEmail, setUserEmail] = useState('');\n  const [userName, setUserName] = useState('');\n  const [customEmailBody, setCustomEmailBody] = useState('');\n  const [sendingTo, setSendingTo] = useState<string | null>(null);\n  const screens = useBreakpoint();\n  const isMobile = !screens.md;\n\n  // Data fetching, handlers, etc.\n\n  return (\n    <PublicLayout>\n      {/* Hero Section */}\n      {/* Step Indicator */}\n      {/* Step Content */}\n      {/* Share Buttons */}\n    </PublicLayout>\n  );\n};\n\nexport default CampaignPage;\n
"},{"location":"v2/frontend/pages/public/campaign-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/public/campaign-page/#component-state","title":"Component State","text":"
// Navigation state\nconst [currentStep, setCurrentStep] = useState(0); // 0=Info, 1=Reps, 2=Send\n\n// Campaign data\nconst [campaign, setCampaign] = useState<Campaign | null>(null);\nconst [loading, setLoading] = useState(true);\n\n// Representative lookup\nconst [postalCode, setPostalCode] = useState('');\nconst [representatives, setRepresentatives] = useState<Representative[]>([]);\nconst [repsLoading, setRepsLoading] = useState(false);\n\n// User input for email\nconst [userEmail, setUserEmail] = useState('');\nconst [userName, setUserName] = useState('');\nconst [customEmailBody, setCustomEmailBody] = useState('');\n\n// Send state\nconst [sendingTo, setSendingTo] = useState<string | null>(null); // Rep email being sent to\n\n// Responsive\nconst screens = useBreakpoint();\nconst isMobile = !screens.md;\n
"},{"location":"v2/frontend/pages/public/campaign-page/#derived-state","title":"Derived State","text":"
// Filtered representatives by government level\nconst filteredReps = representatives.filter(rep => {\n  if (!campaign) return false;\n  // Show all reps if campaign targets multiple levels or 'all'\n  if (campaign.governmentLevel.includes('all')) return true;\n  // Otherwise only show reps matching campaign's government levels\n  return campaign.governmentLevel.includes(rep.government_level);\n});\n\n// Email preview with substitutions\nconst emailPreview = useMemo(() => {\n  if (!campaign) return '';\n\n  let body = customEmailBody || campaign.emailBody;\n\n  // Replace placeholders\n  body = body.replace(/\\{name\\}/g, userName || '[Your Name]');\n  body = body.replace(/\\{email\\}/g, userEmail || '[Your Email]');\n  body = body.replace(/\\{postalCode\\}/g, postalCode || '[Your Postal Code]');\n\n  return body;\n}, [campaign, customEmailBody, userName, userEmail, postalCode]);\n\n// Step navigation enabled states\nconst canProceedToStep2 = !!campaign; // Campaign loaded\nconst canProceedToStep3 = representatives.length > 0; // Reps found\n
"},{"location":"v2/frontend/pages/public/campaign-page/#state-flow","title":"State Flow","text":"
  1. Initial Load: loading=true, fetch campaign by ID
  2. Campaign Loaded: setCampaign(), setLoading(false)
  3. User Enters Postal Code: setPostalCode() updates input
  4. Lookup Triggered: setRepsLoading(true), fetch representatives
  5. Reps Loaded: setRepresentatives(), setRepsLoading(false), auto-advance to step 3
  6. User Customizes Email: setCustomEmailBody() if editing allowed
  7. User Clicks Send: setSendingTo(rep.email), post to API
  8. Send Complete: setSendingTo(null), show success message, increment counter
"},{"location":"v2/frontend/pages/public/campaign-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/public/campaign-page/#endpoints-used","title":"Endpoints Used","text":""},{"location":"v2/frontend/pages/public/campaign-page/#1-get-campaign-by-id","title":"1. Get Campaign by ID","text":"
GET /api/public/campaigns/:id\n

Response:

{\n  \"id\": \"cm1abc123\",\n  \"title\": \"Support Climate Action Bill\",\n  \"description\": \"Urge your representatives to support strong climate legislation...\",\n  \"slug\": \"climate-action-bill\",\n  \"coverPhoto\": \"https://example.com/photos/climate.jpg\",\n  \"governmentLevel\": [\"federal\"],\n  \"targetType\": \"representatives\",\n  \"emailSubject\": \"Please Support Bill C-123\",\n  \"emailBody\": \"Dear {representative},\\n\\nAs your constituent in {postalCode}, I urge you to support Bill C-123...\\n\\nSincerely,\\n{name}\\n{email}\",\n  \"allowEmailEditing\": true,\n  \"isActive\": true,\n  \"emailsSentCount\": 1247,\n  \"responsesCount\": 342,\n  \"createdAt\": \"2025-01-15T10:00:00.000Z\"\n}\n

"},{"location":"v2/frontend/pages/public/campaign-page/#2-lookup-representatives","title":"2. Lookup Representatives","text":"
GET /api/public/representatives/lookup?postalCode=K1A0B1\n

Response:

[\n  {\n    \"name\": \"John Smith\",\n    \"district_name\": \"Ottawa Centre\",\n    \"elected_office\": \"MP\",\n    \"party_name\": \"Liberal\",\n    \"email\": \"john.smith@parl.gc.ca\",\n    \"photo_url\": \"https://represent.opennorth.ca/media/photos/mp-john-smith.jpg\",\n    \"government_level\": \"federal\",\n    \"offices\": [\n      {\n        \"tel\": \"613-555-1234\",\n        \"type\": \"constituency\",\n        \"postal\": \"123 Main St, Ottawa ON K1A 0B1\"\n      }\n    ]\n  }\n]\n

"},{"location":"v2/frontend/pages/public/campaign-page/#3-send-campaign-email","title":"3. Send Campaign Email","text":"
POST /api/public/campaigns/:id/send-email\nContent-Type: application/json\n\n{\n  \"senderName\": \"Jane Doe\",\n  \"senderEmail\": \"jane@example.com\",\n  \"postalCode\": \"K1A 0B1\",\n  \"recipientName\": \"John Smith\",\n  \"recipientEmail\": \"john.smith@parl.gc.ca\",\n  \"customMessage\": \"Dear MP Smith,\\n\\nAs your constituent...\",\n  \"government_level\": \"federal\"\n}\n

Response:

{\n  \"success\": true,\n  \"emailId\": \"cm2def456\",\n  \"message\": \"Email queued for sending\"\n}\n

"},{"location":"v2/frontend/pages/public/campaign-page/#request-examples","title":"Request Examples","text":""},{"location":"v2/frontend/pages/public/campaign-page/#fetch-campaign","title":"Fetch Campaign","text":"
useEffect(() => {\n  const fetchCampaign = async () => {\n    if (!id) {\n      message.error('Invalid campaign ID');\n      return;\n    }\n\n    try {\n      setLoading(true);\n      const response = await axios.get(`/api/public/campaigns/${id}`);\n      setCampaign(response.data);\n      setCustomEmailBody(response.data.emailBody); // Initialize with template\n    } catch (error: any) {\n      console.error('Failed to fetch campaign:', error);\n      if (error.response?.status === 404) {\n        message.error('Campaign not found');\n      } else {\n        message.error('Failed to load campaign');\n      }\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  fetchCampaign();\n}, [id]);\n
"},{"location":"v2/frontend/pages/public/campaign-page/#lookup-representatives","title":"Lookup Representatives","text":"
const handlePostalCodeLookup = async () => {\n  if (!postalCode.trim()) {\n    message.warning('Please enter a postal code');\n    return;\n  }\n\n  try {\n    setRepsLoading(true);\n    const response = await axios.get('/api/public/representatives/lookup', {\n      params: { postalCode: postalCode.trim().toUpperCase() }\n    });\n\n    setRepresentatives(response.data);\n\n    if (response.data.length === 0) {\n      message.info('No representatives found for this postal code');\n    } else {\n      // Auto-advance to step 3\n      setCurrentStep(2);\n      message.success(`Found ${response.data.length} representative(s)`);\n    }\n  } catch (error) {\n    console.error('Lookup failed:', error);\n    message.error('Failed to find representatives. Please check the postal code.');\n  } finally {\n    setRepsLoading(false);\n  }\n};\n
"},{"location":"v2/frontend/pages/public/campaign-page/#send-email","title":"Send Email","text":"
const handleSendEmail = async (rep: Representative) => {\n  if (!userName.trim() || !userEmail.trim()) {\n    message.warning('Please enter your name and email');\n    return;\n  }\n\n  if (!campaign) return;\n\n  try {\n    setSendingTo(rep.email);\n\n    await axios.post(`/api/public/campaigns/${campaign.id}/send-email`, {\n      senderName: userName,\n      senderEmail: userEmail,\n      postalCode: postalCode.toUpperCase(),\n      recipientName: rep.name,\n      recipientEmail: rep.email,\n      customMessage: customEmailBody || campaign.emailBody,\n      government_level: rep.government_level\n    });\n\n    message.success(`Email sent to ${rep.name}!`);\n\n    // Update local counter (optimistic update)\n    setCampaign(prev => prev ? {\n      ...prev,\n      emailsSentCount: prev.emailsSentCount + 1\n    } : null);\n\n  } catch (error: any) {\n    console.error('Failed to send email:', error);\n    message.error(error.response?.data?.message || 'Failed to send email. Please try again.');\n  } finally {\n    setSendingTo(null);\n  }\n};\n
"},{"location":"v2/frontend/pages/public/campaign-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/public/campaign-page/#hero-section-with-statistics","title":"Hero Section with Statistics","text":"
<div style={{ position: 'relative', marginBottom: 32 }}>\n  {/* Cover Photo or Gradient */}\n  <div style={{\n    height: isMobile ? 250 : 400,\n    overflow: 'hidden',\n    position: 'relative',\n    borderRadius: 8\n  }}>\n    {campaign.coverPhoto ? (\n      <img\n        src={campaign.coverPhoto}\n        alt={campaign.title}\n        style={{\n          width: '100%',\n          height: '100%',\n          objectFit: 'cover'\n        }}\n      />\n    ) : (\n      <div style={{\n        width: '100%',\n        height: '100%',\n        background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'\n      }} />\n    )}\n\n    {/* Gradient Overlay */}\n    <div style={{\n      position: 'absolute',\n      bottom: 0,\n      left: 0,\n      right: 0,\n      height: '50%',\n      background: 'linear-gradient(to top, rgba(0,0,0,0.8), transparent)'\n    }} />\n\n    {/* Title Overlay */}\n    <div style={{\n      position: 'absolute',\n      bottom: 24,\n      left: 24,\n      right: isMobile ? 24 : '30%',\n      color: 'white'\n    }}>\n      <Title\n        level={1}\n        style={{\n          color: 'white',\n          marginBottom: 8,\n          fontSize: isMobile ? 24 : 36\n        }}\n      >\n        {campaign.title}\n      </Title>\n    </div>\n\n    {/* Statistics Circles */}\n    <div style={{\n      position: 'absolute',\n      top: 24,\n      right: 24,\n      display: 'flex',\n      flexDirection: isMobile ? 'column' : 'row',\n      gap: 16\n    }}>\n      {/* Emails Sent Circle */}\n      <div style={{\n        background: 'rgba(24, 144, 255, 0.9)',\n        borderRadius: '50%',\n        width: 100,\n        height: 100,\n        display: 'flex',\n        flexDirection: 'column',\n        alignItems: 'center',\n        justifyContent: 'center',\n        color: 'white',\n        boxShadow: '0 4px 12px rgba(0,0,0,0.3)'\n      }}>\n        <MailOutlined style={{ fontSize: 24, marginBottom: 4 }} />\n        <Text strong style={{ color: 'white', fontSize: 20 }}>\n          {campaign.emailsSentCount}\n        </Text>\n        <Text style={{ color: 'white', fontSize: 12 }}>\n          Emails\n        </Text>\n      </div>\n\n      {/* Responses Circle */}\n      <div style={{\n        background: 'rgba(82, 196, 26, 0.9)',\n        borderRadius: '50%',\n        width: 100,\n        height: 100,\n        display: 'flex',\n        flexDirection: 'column',\n        alignItems: 'center',\n        justifyContent: 'center',\n        color: 'white',\n        boxShadow: '0 4px 12px rgba(0,0,0,0.3)'\n      }}>\n        <CommentOutlined style={{ fontSize: 24, marginBottom: 4 }} />\n        <Text strong style={{ color: 'white', fontSize: 20 }}>\n          {campaign.responsesCount}\n        </Text>\n        <Text style={{ color: 'white', fontSize: 12 }}>\n          Responses\n        </Text>\n      </div>\n    </div>\n  </div>\n</div>\n
"},{"location":"v2/frontend/pages/public/campaign-page/#step-indicator","title":"Step Indicator","text":"
<Steps\n  current={currentStep}\n  onChange={setCurrentStep}\n  direction={isMobile ? 'vertical' : 'horizontal'}\n  style={{ marginBottom: 32 }}\n>\n  <Step\n    title=\"Campaign Info\"\n    description={!isMobile && \"Learn about the campaign\"}\n    icon={<MailOutlined />}\n  />\n  <Step\n    title=\"Your Representatives\"\n    description={!isMobile && \"Find your elected officials\"}\n    icon={<SearchOutlined />}\n    disabled={!canProceedToStep2}\n  />\n  <Step\n    title=\"Send Your Message\"\n    description={!isMobile && \"Take action now\"}\n    icon={<SendOutlined />}\n    disabled={!canProceedToStep3}\n  />\n</Steps>\n
"},{"location":"v2/frontend/pages/public/campaign-page/#representative-cards-with-dual-send-options","title":"Representative Cards with Dual Send Options","text":"
<Row gutter={[16, 16]}>\n  {filteredReps.map((rep, idx) => (\n    <Col xs={24} sm={12} lg={8} key={idx}>\n      <Card hoverable>\n        {/* Photo */}\n        <div style={{ textAlign: 'center', marginBottom: 16 }}>\n          <img\n            src={rep.photo_url || '/default-avatar.png'}\n            alt={rep.name}\n            style={{\n              width: 120,\n              height: 120,\n              borderRadius: '50%',\n              objectFit: 'cover',\n              border: '3px solid #1890ff'\n            }}\n          />\n        </div>\n\n        {/* Details */}\n        <Title level={4} style={{ marginBottom: 4, textAlign: 'center' }}>\n          {rep.name}\n        </Title>\n        <Text type=\"secondary\" style={{ display: 'block', textAlign: 'center', marginBottom: 8 }}>\n          {rep.elected_office} \u2022 {rep.district_name}\n        </Text>\n\n        <div style={{ textAlign: 'center', marginBottom: 16 }}>\n          <Tag color=\"blue\">{rep.party_name}</Tag>\n          <Tag color=\"purple\">\n            {rep.government_level.charAt(0).toUpperCase() + rep.government_level.slice(1)}\n          </Tag>\n        </div>\n\n        {/* Contact Info */}\n        <div style={{ marginBottom: 16, fontSize: 12 }}>\n          <Text strong>Email:</Text>\n          <br />\n          <Text copyable style={{ fontSize: 12 }}>{rep.email}</Text>\n          <br /><br />\n\n          {rep.offices?.[0]?.tel && (\n            <>\n              <Text strong>Phone:</Text>\n              <br />\n              <Text style={{ fontSize: 12 }}>{rep.offices[0].tel}</Text>\n              <br /><br />\n            </>\n          )}\n\n          {rep.offices?.[0]?.postal && (\n            <>\n              <Text strong>Office:</Text>\n              <br />\n              <Text type=\"secondary\" style={{ fontSize: 12 }}>\n                {rep.offices[0].postal}\n              </Text>\n            </>\n          )}\n        </div>\n\n        {/* Send Buttons */}\n        <Space direction=\"vertical\" style={{ width: '100%' }}>\n          {/* SMTP Send (Tracked) */}\n          <Button\n            type=\"primary\"\n            icon={<SendOutlined />}\n            block\n            loading={sendingTo === rep.email}\n            onClick={() => handleSendEmail(rep)}\n            disabled={!userName || !userEmail}\n          >\n            Send Email\n          </Button>\n\n          {/* Mailto (Untracked) */}\n          <Button\n            icon={<DesktopOutlined />}\n            block\n            onClick={() => {\n              const subject = encodeURIComponent(campaign.emailSubject);\n              const body = encodeURIComponent(emailPreview);\n              window.location.href = `mailto:${rep.email}?subject=${subject}&body=${body}`;\n            }}\n          >\n            Open in Email App\n          </Button>\n        </Space>\n      </Card>\n    </Col>\n  ))}\n</Row>\n
"},{"location":"v2/frontend/pages/public/campaign-page/#email-preview-with-optional-editing","title":"Email Preview with Optional Editing","text":"
<Card\n  title=\"Email Preview\"\n  style={{ marginBottom: 24 }}\n  extra={\n    campaign.allowEmailEditing && (\n      <Text type=\"secondary\" style={{ fontSize: 12 }}>\n        You can edit this message\n      </Text>\n    )\n  }\n>\n  {/* Subject Line */}\n  <div style={{ marginBottom: 16 }}>\n    <Text strong>Subject:</Text>\n    <br />\n    <Text>{campaign.emailSubject}</Text>\n  </div>\n\n  {/* Email Body */}\n  <div>\n    <Text strong>Message:</Text>\n    {campaign.allowEmailEditing ? (\n      <TextArea\n        value={customEmailBody}\n        onChange={(e) => setCustomEmailBody(e.target.value)}\n        rows={10}\n        style={{ marginTop: 8, fontFamily: 'monospace', fontSize: 13 }}\n      />\n    ) : (\n      <pre style={{\n        marginTop: 8,\n        padding: 16,\n        background: '#f5f5f5',\n        borderRadius: 4,\n        whiteSpace: 'pre-wrap',\n        fontFamily: 'inherit',\n        fontSize: 13\n      }}>\n        {emailPreview}\n      </pre>\n    )}\n  </div>\n\n  {/* Placeholder Legend */}\n  <div style={{\n    marginTop: 16,\n    padding: 12,\n    background: '#e6f7ff',\n    borderRadius: 4,\n    fontSize: 12\n  }}>\n    <Text type=\"secondary\">\n      <strong>Available placeholders:</strong> {'{name}'}, {'{email}'}, {'{postalCode}'}\n    </Text>\n  </div>\n</Card>\n
"},{"location":"v2/frontend/pages/public/campaign-page/#user-information-form","title":"User Information Form","text":"
<Card title=\"Your Information\" style={{ marginBottom: 24 }}>\n  <Form layout=\"vertical\">\n    <Form.Item\n      label=\"Your Name\"\n      required\n      validateStatus={!userName && 'error'}\n      help={!userName && 'Please enter your name'}\n    >\n      <Input\n        size=\"large\"\n        placeholder=\"Jane Doe\"\n        value={userName}\n        onChange={(e) => setUserName(e.target.value)}\n      />\n    </Form.Item>\n\n    <Form.Item\n      label=\"Your Email\"\n      required\n      validateStatus={!userEmail && 'error'}\n      help={!userEmail && 'Please enter your email'}\n    >\n      <Input\n        size=\"large\"\n        type=\"email\"\n        placeholder=\"jane@example.com\"\n        value={userEmail}\n        onChange={(e) => setUserEmail(e.target.value)}\n      />\n    </Form.Item>\n\n    <Form.Item\n      label=\"Postal Code\"\n      required\n      validateStatus={!postalCode && 'error'}\n      help={!postalCode && 'Entered in step 2'}\n    >\n      <Input\n        size=\"large\"\n        disabled\n        value={postalCode}\n        style={{ background: '#f5f5f5' }}\n      />\n    </Form.Item>\n  </Form>\n</Card>\n
"},{"location":"v2/frontend/pages/public/campaign-page/#response-wall-cta","title":"Response Wall CTA","text":"
{campaign.responsesCount > 0 && (\n  <Card\n    style={{\n      marginTop: 32,\n      background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',\n      border: 'none'\n    }}\n  >\n    <div style={{ textAlign: 'center', color: 'white' }}>\n      <CommentOutlined style={{ fontSize: 48, marginBottom: 16 }} />\n      <Title level={3} style={{ color: 'white', marginBottom: 16 }}>\n        See What Others Are Saying\n      </Title>\n      <Paragraph style={{ color: 'rgba(255,255,255,0.9)', marginBottom: 24 }}>\n        Read {campaign.responsesCount} responses from people who took action\n      </Paragraph>\n      <Link to={`/responses/${campaign.id}`}>\n        <Button type=\"default\" size=\"large\">\n          View Response Wall\n        </Button>\n      </Link>\n    </div>\n  </Card>\n)}\n
"},{"location":"v2/frontend/pages/public/campaign-page/#navigation-controls","title":"Navigation Controls","text":"
<div style={{\n  display: 'flex',\n  justifyContent: 'space-between',\n  marginTop: 32,\n  paddingTop: 24,\n  borderTop: '1px solid #303030'\n}}>\n  <Button\n    onClick={() => setCurrentStep(prev => Math.max(0, prev - 1))}\n    disabled={currentStep === 0}\n  >\n    <ArrowLeftOutlined /> Previous\n  </Button>\n\n  {currentStep < 2 ? (\n    <Button\n      type=\"primary\"\n      onClick={() => setCurrentStep(prev => Math.min(2, prev + 1))}\n      disabled={\n        (currentStep === 0 && !campaign) ||\n        (currentStep === 1 && representatives.length === 0)\n      }\n    >\n      Next <ArrowLeftOutlined style={{ transform: 'rotate(180deg)' }} />\n    </Button>\n  ) : (\n    <Text type=\"secondary\">\n      Click \"Send Email\" on any representative card above\n    </Text>\n  )}\n</div>\n
"},{"location":"v2/frontend/pages/public/campaign-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/public/campaign-page/#1-optimized-email-preview-rendering","title":"1. Optimized Email Preview Rendering","text":"

Uses useMemo to avoid re-computing on every render:

const emailPreview = useMemo(() => {\n  if (!campaign) return '';\n\n  let body = customEmailBody || campaign.emailBody;\n\n  body = body.replace(/\\{name\\}/g, userName || '[Your Name]');\n  body = body.replace(/\\{email\\}/g, userEmail || '[Your Email]');\n  body = body.replace(/\\{postalCode\\}/g, postalCode || '[Your Postal Code]');\n\n  return body;\n}, [campaign, customEmailBody, userName, userEmail, postalCode]);\n

Benefit: Preview only recalculates when dependencies change, not on every keystroke.

"},{"location":"v2/frontend/pages/public/campaign-page/#2-auto-advance-after-lookup","title":"2. Auto-advance After Lookup","text":"

Automatically proceeds to step 3 when representatives loaded:

if (response.data.length > 0) {\n  setCurrentStep(2); // Auto-advance\n  message.success(`Found ${response.data.length} representative(s)`);\n}\n

Benefit: Reduces user clicks, smoother workflow.

"},{"location":"v2/frontend/pages/public/campaign-page/#3-optimistic-ui-updates","title":"3. Optimistic UI Updates","text":"

Updates email counter immediately after send (before API response):

message.success(`Email sent to ${rep.name}!`);\n\nsetCampaign(prev => prev ? {\n  ...prev,\n  emailsSentCount: prev.emailsSentCount + 1\n} : null);\n

Benefit: Instant feedback, perceived performance improvement.

"},{"location":"v2/frontend/pages/public/campaign-page/#4-conditional-component-rendering","title":"4. Conditional Component Rendering","text":"

Response wall CTA only renders if responses exist:

{campaign.responsesCount > 0 && (\n  <Card>{/* Response wall CTA */}</Card>\n)}\n

Benefit: Cleaner DOM, faster initial render for new campaigns.

"},{"location":"v2/frontend/pages/public/campaign-page/#5-debounced-representative-filtering","title":"5. Debounced Representative Filtering","text":"

Filtering happens on blur/Enter, not on every keystroke:

<Input\n  onBlur={handlePostalCodeLookup}\n  onPressEnter={handlePostalCodeLookup}\n  // NOT: onChange={handlePostalCodeLookup}\n/>\n

Benefit: Prevents excessive API calls while user types.

"},{"location":"v2/frontend/pages/public/campaign-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/public/campaign-page/#breakpoint-behavior","title":"Breakpoint Behavior","text":"Breakpoint Hero Height Stats Position Steps Direction Rep Cards Columns xs (0-575px) 250px Vertical stack Vertical 1 sm (576-767px) 250px Vertical stack Vertical 2 md (768-991px) 400px Horizontal row Horizontal 2 lg (992px+) 400px Horizontal row Horizontal 3"},{"location":"v2/frontend/pages/public/campaign-page/#mobile-adaptations","title":"Mobile Adaptations","text":"

Hero Section: - Reduced height (250px vs 400px) - Statistics circles stack vertically - Title font size reduced (24px vs 36px) - Right margin for title increased to prevent overlap with stats

Steps Component: - Switches to vertical orientation - Step descriptions hidden on mobile (takes too much space) - Icons remain visible for visual guidance

Representative Cards: - Single column layout on xs - Two columns on sm (tablet portrait) - Three columns on lg+ (desktop)

Form Inputs: - Full-width inputs on mobile - size=\"large\" for better touch targets - Increased spacing between fields

Email Preview: - TextArea expands to full width - Font size slightly smaller (13px) for better fit - Scrollable if content exceeds viewport

"},{"location":"v2/frontend/pages/public/campaign-page/#tablet-optimization","title":"Tablet Optimization","text":"

At sm breakpoint (576-767px): - Rep cards show 2 per row (good balance) - Hero maintains mobile height (better above-fold) - Steps remain vertical (clearer on narrow viewports) - Send buttons remain full-width within cards

"},{"location":"v2/frontend/pages/public/campaign-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/public/campaign-page/#keyboard-navigation","title":"Keyboard Navigation","text":"

Step Navigation: - Steps component is keyboard accessible (Tab + Enter) - Arrow keys navigate between steps (native Ant Design) - Space bar activates step

Form Fields: - All inputs focusable via Tab - Enter key submits postal code lookup - Escape key can close modals (future feature)

Send Buttons: - Both \"Send Email\" and \"Open in Email App\" are focusable - Enter/Space activates button - Loading state prevents double-submission

"},{"location":"v2/frontend/pages/public/campaign-page/#aria-labels","title":"ARIA Labels","text":"

Step Indicator:

<Steps\n  current={currentStep}\n  aria-label=\"Campaign action steps\"\n>\n  <Step\n    title=\"Campaign Info\"\n    icon={<MailOutlined aria-hidden=\"true\" />}\n  />\n</Steps>\n

Representative Photos:

<img\n  src={rep.photo_url}\n  alt={`Photo of ${rep.name}, ${rep.elected_office} for ${rep.district_name}`}\n  role=\"img\"\n/>\n

Loading States:

<Spin\n  size=\"small\"\n  aria-label=\"Loading representatives\"\n/>\n\n<Button\n  loading={sendingTo === rep.email}\n  aria-label={`Sending email to ${rep.name}`}\n>\n  Send Email\n</Button>\n

"},{"location":"v2/frontend/pages/public/campaign-page/#screen-reader-support","title":"Screen Reader Support","text":"

Step Announcements: - Current step announced when changed - Step titles are clear and descriptive - Disabled steps have appropriate aria-disabled attribute

Form Validation:

<Form.Item\n  label=\"Your Name\"\n  required\n  validateStatus={!userName && 'error'}\n  help={!userName && 'Please enter your name'}\n  aria-required=\"true\"\n>\n  <Input />\n</Form.Item>\n

Success/Error Messages: - Ant Design message component has ARIA live region - Screen reader announces \"Email sent successfully!\" - Error messages also announced automatically

Email Preview:

<pre\n  role=\"article\"\n  aria-label=\"Email message preview\"\n>\n  {emailPreview}\n</pre>\n

"},{"location":"v2/frontend/pages/public/campaign-page/#color-contrast","title":"Color Contrast","text":"

Statistics Circles: - Blue circle: #1890ff on white text (4.5:1 ratio \u2713) - Green circle: #52c41a on white text (4.7:1 ratio \u2713) - Both meet WCAG AA standards

Primary Buttons: - Ant Design primary button (#1890ff) meets AA contrast - Focus outline visible on all interactive elements

Text Hierarchy: - Primary text: white on #0d1b2a (15.8:1 ratio \u2713\u2713) - Secondary text: rgba(255,255,255,0.65) on dark (7.2:1 ratio \u2713) - Links: #1890ff with underline on focus

"},{"location":"v2/frontend/pages/public/campaign-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/public/campaign-page/#issue-representatives-not-filtered-by-government-level","title":"Issue: Representatives Not Filtered by Government Level","text":"

Symptoms: - Federal campaign shows provincial/municipal reps - All reps display regardless of campaign targets - Filtering logic not working

Causes: 1. government_level field missing in API response 2. governmentLevel array empty in campaign 3. Case mismatch (Federal vs federal) 4. Filtering logic bug

Solutions:

// Add debug logging\nuseEffect(() => {\n  if (representatives.length > 0 && campaign) {\n    console.log('Campaign levels:', campaign.governmentLevel);\n    console.log('Rep levels:', representatives.map(r => r.government_level));\n    console.log('Filtered count:', filteredReps.length);\n  }\n}, [representatives, campaign]);\n\n// Robust filtering with case-insensitive matching\nconst filteredReps = representatives.filter(rep => {\n  if (!campaign || !rep.government_level) return false;\n\n  // Normalize to lowercase for comparison\n  const campaignLevels = campaign.governmentLevel.map(l => l.toLowerCase());\n  const repLevel = rep.government_level.toLowerCase();\n\n  // Show all if campaign targets 'all' levels\n  if (campaignLevels.includes('all')) return true;\n\n  // Otherwise match exact level\n  return campaignLevels.includes(repLevel);\n});\n\n// Add fallback if no filtered reps\n{filteredReps.length === 0 && representatives.length > 0 && (\n  <Alert\n    type=\"warning\"\n    message=\"No matching representatives\"\n    description={`This campaign targets ${campaign.governmentLevel.join(', ')} representatives, but none were found for your postal code at that level.`}\n    style={{ marginBottom: 16 }}\n  />\n)}\n

Check API response:

# Verify government_level field present\ncurl http://localhost:4000/api/public/representatives/lookup?postalCode=K1A0B1 | jq '.[].government_level'\n# Should output: \"federal\", \"provincial\", etc.\n

"},{"location":"v2/frontend/pages/public/campaign-page/#issue-email-preview-not-updating","title":"Issue: Email Preview Not Updating","text":"

Symptoms: - Placeholders remain as {name} instead of actual values - User input not reflected in preview - Preview frozen on initial template

Causes: 1. useMemo dependencies missing 2. State not updating properly 3. Placeholder regex not matching 4. Component not re-rendering

Solutions:

// Ensure all dependencies in useMemo\nconst emailPreview = useMemo(() => {\n  if (!campaign) return '';\n\n  let body = customEmailBody || campaign.emailBody;\n\n  // Use global replace with /g flag\n  body = body.replace(/\\{name\\}/g, userName || '[Your Name]');\n  body = body.replace(/\\{email\\}/g, userEmail || '[Your Email]');\n  body = body.replace(/\\{postalCode\\}/g, postalCode || '[Your Postal Code]');\n\n  // Log for debugging\n  console.log('Preview updated:', {\n    userName,\n    userEmail,\n    postalCode,\n    bodyLength: body.length\n  });\n\n  return body;\n}, [campaign, customEmailBody, userName, userEmail, postalCode]);\n// ^^^ All dependencies must be listed\n\n// Alternative: Force re-render with key\n<pre key={`${userName}-${userEmail}-${postalCode}`}>\n  {emailPreview}\n</pre>\n
"},{"location":"v2/frontend/pages/public/campaign-page/#issue-send-button-not-working","title":"Issue: Send Button Not Working","text":"

Symptoms: - Clicking \"Send Email\" does nothing - No API request in Network tab - Button not disabled/loading

Causes: 1. Missing form validation 2. Event handler not bound 3. API endpoint incorrect 4. CORS error blocking request

Solutions:

// Add comprehensive validation\nconst handleSendEmail = async (rep: Representative) => {\n  // Validate user input\n  if (!userName.trim()) {\n    message.error('Please enter your name');\n    return;\n  }\n\n  if (!userEmail.trim()) {\n    message.error('Please enter your email');\n    return;\n  }\n\n  if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(userEmail)) {\n    message.error('Please enter a valid email address');\n    return;\n  }\n\n  if (!postalCode.trim()) {\n    message.error('Postal code is required (from step 2)');\n    return;\n  }\n\n  if (!campaign) {\n    message.error('Campaign data not loaded');\n    return;\n  }\n\n  // Log request details\n  console.log('Sending email:', {\n    campaignId: campaign.id,\n    to: rep.email,\n    from: userEmail\n  });\n\n  try {\n    setSendingTo(rep.email);\n\n    const payload = {\n      senderName: userName.trim(),\n      senderEmail: userEmail.trim(),\n      postalCode: postalCode.trim().toUpperCase(),\n      recipientName: rep.name,\n      recipientEmail: rep.email,\n      customMessage: customEmailBody || campaign.emailBody,\n      government_level: rep.government_level\n    };\n\n    console.log('Payload:', payload);\n\n    const response = await axios.post(\n      `/api/public/campaigns/${campaign.id}/send-email`,\n      payload,\n      { timeout: 10000 } // 10s timeout\n    );\n\n    console.log('Response:', response.data);\n\n    message.success(`Email sent to ${rep.name}!`);\n\n    // Optimistic update\n    setCampaign(prev => prev ? {\n      ...prev,\n      emailsSentCount: prev.emailsSentCount + 1\n    } : null);\n\n  } catch (error: any) {\n    console.error('Send error:', error);\n\n    if (error.code === 'ECONNABORTED') {\n      message.error('Request timed out. Please try again.');\n    } else if (error.response) {\n      message.error(error.response.data?.message || 'Failed to send email');\n    } else {\n      message.error('Network error. Please check your connection.');\n    }\n  } finally {\n    setSendingTo(null);\n  }\n};\n

Check CORS configuration:

// In api/src/server.ts\napp.use(cors({\n  origin: process.env.CORS_ORIGIN || 'http://localhost:3000',\n  credentials: true\n}));\n

"},{"location":"v2/frontend/pages/public/campaign-page/#issue-auto-advance-to-step-3-not-working","title":"Issue: Auto-advance to Step 3 Not Working","text":"

Symptoms: - Representatives load but page stays on step 2 - User must manually click \"Next\" - Auto-advance logic not triggering

Causes: 1. State update timing issue 2. Conditional check failing 3. React Strict Mode double-rendering 4. Missing setCurrentStep(2) call

Solutions:

// Move auto-advance inside success branch\nconst handlePostalCodeLookup = async () => {\n  if (!postalCode.trim()) {\n    message.warning('Please enter a postal code');\n    return;\n  }\n\n  try {\n    setRepsLoading(true);\n    const response = await axios.get('/api/public/representatives/lookup', {\n      params: { postalCode: postalCode.trim().toUpperCase() }\n    });\n\n    setRepresentatives(response.data);\n\n    // Auto-advance ONLY if reps found\n    if (response.data.length > 0) {\n      // Use setTimeout to ensure state update completes\n      setTimeout(() => {\n        setCurrentStep(2);\n        message.success(`Found ${response.data.length} representative(s)`);\n      }, 100);\n    } else {\n      message.info('No representatives found for this postal code');\n    }\n  } catch (error) {\n    console.error('Lookup failed:', error);\n    message.error('Failed to find representatives');\n  } finally {\n    setRepsLoading(false);\n  }\n};\n\n// Alternative: Use useEffect to watch for reps\nuseEffect(() => {\n  if (representatives.length > 0 && currentStep === 1) {\n    setCurrentStep(2);\n  }\n}, [representatives.length, currentStep]);\n
"},{"location":"v2/frontend/pages/public/campaign-page/#issue-mailto-links-not-working","title":"Issue: Mailto Links Not Working","text":"

Symptoms: - Clicking \"Open in Email App\" does nothing - Browser blocks mailto: protocol - Email client doesn't open

Causes: 1. Browser security settings blocking mailto 2. No default email client configured 3. URL encoding issues 4. Email body too long (URL length limit)

Solutions:

// Add error handling for mailto\nconst handleMailtoClick = (rep: Representative) => {\n  try {\n    const subject = encodeURIComponent(campaign.emailSubject);\n    const body = encodeURIComponent(emailPreview);\n\n    // Check URL length (browsers have ~2000 char limit)\n    const mailtoUrl = `mailto:${rep.email}?subject=${subject}&body=${body}`;\n\n    if (mailtoUrl.length > 2000) {\n      message.warning(\n        'Email message is too long for mailto link. ' +\n        'Please use the \"Send Email\" button instead.',\n        5\n      );\n      return;\n    }\n\n    // Try to open mailto\n    window.location.href = mailtoUrl;\n\n    // Show informative message\n    message.info(\n      'Opening your email client. If nothing happens, please check your browser settings.',\n      5\n    );\n\n  } catch (error) {\n    console.error('Mailto error:', error);\n    message.error('Failed to open email client. Please use the \"Send Email\" button instead.');\n  }\n};\n\n// Update button\n<Button\n  icon={<DesktopOutlined />}\n  block\n  onClick={() => handleMailtoClick(rep)}\n>\n  Open in Email App\n</Button>\n
"},{"location":"v2/frontend/pages/public/campaign-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/public/campaign-page/#public-pages","title":"Public Pages","text":""},{"location":"v2/frontend/pages/public/campaign-page/#admin-pages","title":"Admin Pages","text":""},{"location":"v2/frontend/pages/public/campaign-page/#components","title":"Components","text":""},{"location":"v2/frontend/pages/public/campaign-page/#api-documentation","title":"API Documentation","text":""},{"location":"v2/frontend/pages/public/campaign-page/#architecture","title":"Architecture","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/","title":"Campaigns List Page","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/public/CampaignsListPage.tsx (566 lines)

Route: /campaigns

Role Requirements: Public access (no authentication required)

Purpose: Primary landing page for the advocacy campaign system, providing a browseable directory of active campaigns with featured campaign highlighting, postal code-based representative lookup, and social sharing capabilities.

Key Features:

Layout: Uses PublicLayout component with dark theme (colorBgBase: '#0d1b2a', colorBgContainer: '#1b2838')

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#1-hero-banner","title":"1. Hero Banner","text":"

The hero section provides visual branding and context:

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#2-find-your-representatives-section","title":"2. Find Your Representatives Section","text":"

Postal code lookup interface for representative discovery:

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#3-featured-campaign-card","title":"3. Featured Campaign Card","text":"

Highlighted campaign with premium styling:

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#4-campaigns-grid","title":"4. Campaigns Grid","text":"

Responsive grid layout for all campaigns:

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#5-social-sharing","title":"5. Social Sharing","text":"

ShareButtons component integration:

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#6-empty-states","title":"6. Empty States","text":"

Graceful handling of no-data scenarios:

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#initial-page-load","title":"Initial Page Load","text":"
  1. User navigates to /campaigns
  2. PublicLayout renders with dark theme
  3. Component fetches settings from /api/settings
  4. Component fetches campaigns from /api/public/campaigns
  5. Hero banner displays organization name
  6. Campaigns grid renders with featured campaign (if exists) highlighted
  7. ShareButtons component appears at bottom
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#representative-lookup-flow","title":"Representative Lookup Flow","text":"
  1. User enters postal code in \"Find Your Representatives\" input
  2. On blur or Enter key, component triggers lookup
  3. Loading spinner appears in input suffix
  4. API request to /api/public/representatives/lookup?postalCode=X
  5. Results display in grid format with rep cards
  6. User can view contact details for each representative
  7. Empty state message if no results found
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#campaign-browsing","title":"Campaign Browsing","text":"
  1. User scrolls through campaigns grid
  2. Featured campaign (if exists) appears first with gold border
  3. User clicks \"View Campaign\" on any card
  4. Navigation to /campaigns/:id detail page
  5. Statistics update dynamically based on campaign activity
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#social-sharing","title":"Social Sharing","text":"
  1. User scrolls to bottom of page
  2. User clicks desired social platform icon
  3. Platform-specific share dialog opens (new window)
  4. For \"Copy Link\", URL copied to clipboard with notification
  5. User can share to multiple platforms sequentially
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#component-structure","title":"Component Structure","text":"
import React, { useState, useEffect } from 'react';\nimport { Link } from 'react-router-dom';\nimport { Row, Col, Card, Typography, Input, Spin, message, Tag, Grid } from 'antd';\nimport {\n  MailOutlined,\n  SearchOutlined,\n  CommentOutlined,\n  StarFilled,\n  InboxOutlined\n} from '@ant-design/icons';\nimport PublicLayout from '../../components/PublicLayout';\nimport ShareButtons from '../../components/ShareButtons';\nimport axios from 'axios';\n\nconst { Title, Paragraph, Text } = Typography;\nconst { useBreakpoint } = Grid;\n\ninterface Campaign {\n  id: string;\n  title: string;\n  description: string | null;\n  slug: string;\n  coverPhoto: string | null;\n  governmentLevel: string[];\n  targetType: string;\n  isFeatured: boolean;\n  isActive: boolean;\n  emailsSentCount: number;\n  responsesCount: number;\n}\n\ninterface Representative {\n  name: string;\n  district_name: string;\n  elected_office: string;\n  party_name: string;\n  email: string;\n  photo_url: string;\n  offices: Array<{\n    tel: string;\n    type: string;\n    postal: string;\n  }>;\n}\n\ninterface Settings {\n  organizationName: string;\n}\n\nconst CampaignsListPage: React.FC = () => {\n  const [campaigns, setCampaigns] = useState<Campaign[]>([]);\n  const [settings, setSettings] = useState<Settings | null>(null);\n  const [loading, setLoading] = useState(true);\n  const [postalCode, setPostalCode] = useState('');\n  const [representatives, setRepresentatives] = useState<Representative[]>([]);\n  const [repsLoading, setRepsLoading] = useState(false);\n  const screens = useBreakpoint();\n  const isMobile = !screens.md;\n\n  // Data fetching, event handlers, etc.\n\n  return (\n    <PublicLayout>\n      {/* Hero Banner */}\n      <div className=\"hero-banner\">\n        {/* Content */}\n      </div>\n\n      {/* Find Your Representatives */}\n      <div className=\"find-reps-section\">\n        {/* Postal code input and results */}\n      </div>\n\n      {/* Campaigns Grid */}\n      <div className=\"campaigns-grid\">\n        <Row gutter={[24, 24]}>\n          {/* Featured campaign */}\n          {/* Regular campaigns */}\n        </Row>\n      </div>\n\n      {/* Social Sharing */}\n      <ShareButtons\n        url={window.location.href}\n        title=\"Check out these advocacy campaigns!\"\n      />\n    </PublicLayout>\n  );\n};\n\nexport default CampaignsListPage;\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#component-state","title":"Component State","text":"
// Campaign data state\nconst [campaigns, setCampaigns] = useState<Campaign[]>([]);\nconst [loading, setLoading] = useState(true);\n\n// Settings state\nconst [settings, setSettings] = useState<Settings | null>(null);\n\n// Representative lookup state\nconst [postalCode, setPostalCode] = useState('');\nconst [representatives, setRepresentatives] = useState<Representative[]>([]);\nconst [repsLoading, setRepsLoading] = useState(false);\n\n// Responsive design state\nconst screens = useBreakpoint();\nconst isMobile = !screens.md;\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#derived-state","title":"Derived State","text":"
// Separate featured and regular campaigns\nconst featuredCampaign = campaigns.find(c => c.isFeatured);\nconst regularCampaigns = campaigns.filter(c => !c.isFeatured);\n\n// Filter active campaigns only (done server-side in API)\n// API returns only isActive=true campaigns\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#state-flow","title":"State Flow","text":"
  1. Initial Load: loading=true, fetch campaigns and settings in parallel
  2. Data Received: setCampaigns(), setSettings(), setLoading(false)
  3. Postal Code Entry: User types, setPostalCode() updates state
  4. Lookup Trigger: On blur/Enter, setRepsLoading(true), fetch reps
  5. Reps Received: setRepresentatives(), setRepsLoading(false)
  6. Error Handling: Display message.error(), reset loading states
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#endpoints-used","title":"Endpoints Used","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#1-get-settings","title":"1. Get Settings","text":"
GET /api/settings\n

Response:

{\n  \"organizationName\": \"Progressive Action Network\",\n  \"contactEmail\": \"contact@example.org\",\n  \"allowPublicRegistration\": true,\n  \"defaultMapCenter\": [45.5017, -73.5673],\n  \"defaultMapZoom\": 12\n}\n

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#2-list-public-campaigns","title":"2. List Public Campaigns","text":"
GET /api/public/campaigns\n

Response:

[\n  {\n    \"id\": \"cm1abc123\",\n    \"title\": \"Support Climate Action Bill\",\n    \"description\": \"Urge your representatives to support strong climate legislation\",\n    \"slug\": \"climate-action-bill\",\n    \"coverPhoto\": \"https://example.com/photos/climate.jpg\",\n    \"governmentLevel\": [\"federal\"],\n    \"targetType\": \"representatives\",\n    \"isFeatured\": true,\n    \"isActive\": true,\n    \"emailsSentCount\": 1247,\n    \"responsesCount\": 342,\n    \"createdAt\": \"2025-01-15T10:00:00.000Z\",\n    \"updatedAt\": \"2025-02-10T14:30:00.000Z\"\n  }\n]\n

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#3-lookup-representatives","title":"3. Lookup Representatives","text":"
GET /api/public/representatives/lookup?postalCode=K1A0B1\n

Response:

[\n  {\n    \"name\": \"John Smith\",\n    \"district_name\": \"Ottawa Centre\",\n    \"elected_office\": \"MP\",\n    \"party_name\": \"Liberal\",\n    \"email\": \"john.smith@parl.gc.ca\",\n    \"photo_url\": \"https://represent.opennorth.ca/media/photos/mp-john-smith.jpg\",\n    \"offices\": [\n      {\n        \"tel\": \"613-555-1234\",\n        \"type\": \"constituency\",\n        \"postal\": \"123 Main St, Ottawa ON K1A 0B1\"\n      }\n    ],\n    \"government_level\": \"federal\"\n  }\n]\n

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#request-examples","title":"Request Examples","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#fetch-campaigns","title":"Fetch Campaigns","text":"
useEffect(() => {\n  const fetchData = async () => {\n    try {\n      setLoading(true);\n      const [campaignsRes, settingsRes] = await Promise.all([\n        axios.get('/api/public/campaigns'),\n        axios.get('/api/settings')\n      ]);\n      setCampaigns(campaignsRes.data);\n      setSettings(settingsRes.data);\n    } catch (error) {\n      console.error('Failed to fetch data:', error);\n      message.error('Failed to load campaigns');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  fetchData();\n}, []);\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#lookup-representatives","title":"Lookup Representatives","text":"
const handlePostalCodeLookup = async () => {\n  if (!postalCode.trim()) {\n    message.warning('Please enter a postal code');\n    return;\n  }\n\n  try {\n    setRepsLoading(true);\n    const response = await axios.get('/api/public/representatives/lookup', {\n      params: { postalCode: postalCode.trim().toUpperCase() }\n    });\n    setRepresentatives(response.data);\n\n    if (response.data.length === 0) {\n      message.info('No representatives found for this postal code');\n    }\n  } catch (error) {\n    console.error('Lookup failed:', error);\n    message.error('Failed to find representatives. Please check the postal code.');\n  } finally {\n    setRepsLoading(false);\n  }\n};\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#hero-banner-component","title":"Hero Banner Component","text":"
<div style={{\n  background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',\n  padding: isMobile ? '60px 20px' : '80px 40px',\n  textAlign: 'center',\n  marginBottom: 48,\n  borderRadius: 8\n}}>\n  <Title\n    level={1}\n    style={{\n      color: 'white',\n      marginBottom: 16,\n      fontSize: isMobile ? 24 : 32\n    }}\n  >\n    {settings?.organizationName || 'Changemaker Lite'}\n  </Title>\n  <Paragraph\n    style={{\n      color: 'rgba(255,255,255,0.9)',\n      fontSize: isMobile ? 16 : 18,\n      maxWidth: 600,\n      margin: '0 auto'\n    }}\n  >\n    <MailOutlined style={{ marginRight: 8 }} />\n    Join thousands taking action on the issues that matter\n  </Paragraph>\n</div>\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#representative-lookup-section","title":"Representative Lookup Section","text":"
<div style={{\n  background: theme.token.colorBgContainer,\n  padding: isMobile ? 24 : 40,\n  borderRadius: 8,\n  marginBottom: 48\n}}>\n  <Title level={2} style={{ textAlign: 'center', marginBottom: 24 }}>\n    Find Your Representatives\n  </Title>\n\n  <Input\n    size=\"large\"\n    placeholder=\"Enter your postal code (e.g., K1A 0B1)\"\n    prefix={<SearchOutlined />}\n    suffix={repsLoading ? <Spin size=\"small\" /> : null}\n    value={postalCode}\n    onChange={(e) => setPostalCode(e.target.value)}\n    onBlur={handlePostalCodeLookup}\n    onPressEnter={handlePostalCodeLookup}\n    style={{\n      maxWidth: 500,\n      display: 'block',\n      margin: '0 auto 24px'\n    }}\n  />\n\n  {representatives.length > 0 && (\n    <Row gutter={[16, 16]}>\n      {representatives.map((rep, idx) => (\n        <Col xs={24} sm={12} lg={8} key={idx}>\n          <Card hoverable>\n            <div style={{ textAlign: 'center' }}>\n              <img\n                src={rep.photo_url || '/default-avatar.png'}\n                alt={rep.name}\n                style={{\n                  width: 150,\n                  height: 150,\n                  borderRadius: '50%',\n                  objectFit: 'cover',\n                  marginBottom: 16\n                }}\n              />\n              <Title level={4} style={{ marginBottom: 4 }}>\n                {rep.name}\n              </Title>\n              <Text type=\"secondary\">\n                {rep.elected_office} \u2022 {rep.district_name}\n              </Text>\n              <div style={{ marginTop: 12 }}>\n                <Tag color=\"blue\">{rep.party_name}</Tag>\n              </div>\n              <div style={{ marginTop: 16, textAlign: 'left' }}>\n                <Text strong>Email:</Text>\n                <br />\n                <Text copyable>{rep.email}</Text>\n                <br /><br />\n                {rep.offices?.[0] && (\n                  <>\n                    <Text strong>Phone:</Text>\n                    <br />\n                    <Text>{rep.offices[0].tel}</Text>\n                    <br /><br />\n                    <Text strong>Address:</Text>\n                    <br />\n                    <Text type=\"secondary\">{rep.offices[0].postal}</Text>\n                  </>\n                )}\n              </div>\n            </div>\n          </Card>\n        </Col>\n      ))}\n    </Row>\n  )}\n</div>\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#featured-campaign-card","title":"Featured Campaign Card","text":"
{featuredCampaign && (\n  <Col span={24} key={featuredCampaign.id}>\n    <Card\n      hoverable\n      style={{\n        border: '2px solid #f39c12',\n        boxShadow: '0 4px 12px rgba(243, 156, 18, 0.3)',\n        position: 'relative'\n      }}\n      cover={\n        <div style={{ position: 'relative', height: 300, overflow: 'hidden' }}>\n          {featuredCampaign.coverPhoto ? (\n            <img\n              src={featuredCampaign.coverPhoto}\n              alt={featuredCampaign.title}\n              style={{\n                width: '100%',\n                height: '100%',\n                objectFit: 'cover'\n              }}\n            />\n          ) : (\n            <div style={{\n              width: '100%',\n              height: '100%',\n              background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'\n            }} />\n          )}\n          <div style={{\n            position: 'absolute',\n            top: 16,\n            right: 16,\n            background: 'rgba(243, 156, 18, 0.9)',\n            color: 'white',\n            padding: '8px 16px',\n            borderRadius: 4,\n            display: 'flex',\n            alignItems: 'center',\n            gap: 8\n          }}>\n            <StarFilled />\n            <Text strong style={{ color: 'white' }}>\n              Featured Campaign\n            </Text>\n          </div>\n        </div>\n      }\n    >\n      <Title level={3} style={{ marginBottom: 12 }}>\n        {featuredCampaign.title}\n      </Title>\n\n      <Paragraph\n        ellipsis={{ rows: 2 }}\n        style={{ marginBottom: 16 }}\n      >\n        {featuredCampaign.description}\n      </Paragraph>\n\n      <div style={{ marginBottom: 16 }}>\n        {featuredCampaign.governmentLevel.map(level => (\n          <Tag key={level} color=\"blue\">\n            {level.charAt(0).toUpperCase() + level.slice(1)}\n          </Tag>\n        ))}\n      </div>\n\n      <Row gutter={16} style={{ marginBottom: 16 }}>\n        <Col span={12}>\n          <div style={{ textAlign: 'center' }}>\n            <MailOutlined style={{ fontSize: 24, color: '#1890ff' }} />\n            <div>\n              <Text strong>{featuredCampaign.emailsSentCount}</Text>\n              <br />\n              <Text type=\"secondary\">Emails Sent</Text>\n            </div>\n          </div>\n        </Col>\n        <Col span={12}>\n          <div style={{ textAlign: 'center' }}>\n            <CommentOutlined style={{ fontSize: 24, color: '#52c41a' }} />\n            <div>\n              <Text strong>{featuredCampaign.responsesCount}</Text>\n              <br />\n              <Text type=\"secondary\">Responses</Text>\n            </div>\n          </div>\n        </Col>\n      </Row>\n\n      <Link to={`/campaigns/${featuredCampaign.id}`}>\n        <Button type=\"primary\" block size=\"large\">\n          View Campaign\n        </Button>\n      </Link>\n    </Card>\n  </Col>\n)}\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#regular-campaign-cards","title":"Regular Campaign Cards","text":"
{regularCampaigns.map((campaign) => (\n  <Col xs={24} sm={12} lg={8} key={campaign.id}>\n    <Card\n      hoverable\n      cover={\n        <div style={{ height: 200, overflow: 'hidden' }}>\n          {campaign.coverPhoto ? (\n            <img\n              src={campaign.coverPhoto}\n              alt={campaign.title}\n              style={{\n                width: '100%',\n                height: '100%',\n                objectFit: 'cover'\n              }}\n            />\n          ) : (\n            <div style={{\n              width: '100%',\n              height: '100%',\n              background: 'linear-gradient(135deg, #3498db 0%, #8e44ad 100%)'\n            }} />\n          )}\n        </div>\n      }\n    >\n      <Title level={4} style={{ marginBottom: 8 }}>\n        {campaign.title}\n      </Title>\n\n      <Paragraph\n        ellipsis={{ rows: 2 }}\n        type=\"secondary\"\n        style={{ marginBottom: 12, minHeight: 44 }}\n      >\n        {campaign.description || 'No description available'}\n      </Paragraph>\n\n      <div style={{ marginBottom: 12 }}>\n        {campaign.governmentLevel.map(level => (\n          <Tag key={level} color=\"purple\">\n            {level.charAt(0).toUpperCase() + level.slice(1)}\n          </Tag>\n        ))}\n      </div>\n\n      <Row gutter={8} style={{ marginBottom: 12, fontSize: 12 }}>\n        <Col span={12}>\n          <MailOutlined /> {campaign.emailsSentCount} sent\n        </Col>\n        <Col span={12}>\n          <CommentOutlined /> {campaign.responsesCount} responses\n        </Col>\n      </Row>\n\n      <Link to={`/campaigns/${campaign.id}`}>\n        <Button type=\"link\" block>\n          View Campaign \u2192\n        </Button>\n      </Link>\n    </Card>\n  </Col>\n))}\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#empty-state","title":"Empty State","text":"
{!loading && campaigns.length === 0 && (\n  <div style={{\n    textAlign: 'center',\n    padding: 60,\n    background: theme.token.colorBgContainer,\n    borderRadius: 8\n  }}>\n    <InboxOutlined style={{ fontSize: 64, color: '#999', marginBottom: 16 }} />\n    <Title level={3} type=\"secondary\">\n      No campaigns available\n    </Title>\n    <Paragraph type=\"secondary\">\n      Check back soon for new advocacy opportunities!\n    </Paragraph>\n  </div>\n)}\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#1-parallel-data-fetching","title":"1. Parallel Data Fetching","text":"

Campaigns and settings fetched simultaneously using Promise.all():

const [campaignsRes, settingsRes] = await Promise.all([\n  axios.get('/api/public/campaigns'),\n  axios.get('/api/settings')\n]);\n

Benefit: Reduces initial page load time by ~50% vs sequential requests.

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#2-image-loading-optimization","title":"2. Image Loading Optimization","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#3-conditional-rendering","title":"3. Conditional Rendering","text":"

Representative lookup section only renders when results exist:

{representatives.length > 0 && (\n  <Row gutter={[16, 16]}>\n    {/* Rep cards */}\n  </Row>\n)}\n

Benefit: Avoids unnecessary DOM nodes and improves TTI (Time to Interactive).

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#4-responsive-grid-optimization","title":"4. Responsive Grid Optimization","text":"

Ant Design Grid uses CSS Grid under the hood:

<Row gutter={[24, 24]}>\n  <Col xs={24} sm={12} lg={8}>\n

Benefit: No JavaScript-based layout calculations, pure CSS performance.

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#5-memoization-opportunities-future-enhancement","title":"5. Memoization Opportunities (Future Enhancement)","text":"

Featured/regular campaign split could use useMemo:

const { featuredCampaign, regularCampaigns } = useMemo(() => ({\n  featuredCampaign: campaigns.find(c => c.isFeatured),\n  regularCampaigns: campaigns.filter(c => !c.isFeatured)\n}), [campaigns]);\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#breakpoint-behavior","title":"Breakpoint Behavior","text":"
const screens = useBreakpoint();\nconst isMobile = !screens.md; // md breakpoint = 768px\n
Breakpoint Hero Padding Hero Font Grid Columns Rep Cards xs (0-575px) 60px 20px 24px 1 1 sm (576-767px) 60px 20px 24px 2 2 md (768-991px) 80px 40px 32px 2 2 lg (992px+) 80px 40px 32px 3 3"},{"location":"v2/frontend/pages/public/campaigns-list-page/#mobile-adaptations","title":"Mobile Adaptations","text":"

Hero Banner: - Reduced padding (60px vs 80px vertical) - Smaller title font (24px vs 32px) - Maintained gradient for visual impact

Representative Cards: - Stack to single column on mobile - Maintain circular avatar size (150px) - Full-width buttons for better touch targets

Campaign Cards: - Single column layout on mobile - Cover photo height remains 200px (cropped if needed) - Action buttons become full-width

Find Your Representatives Input: - Full-width on mobile (maxWidth: 500px on desktop) - Larger touch target (size=\"large\") - Enter key triggers lookup for better mobile UX

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#tablet-optimization","title":"Tablet Optimization","text":"

At sm breakpoint (576-767px): - Campaign grid shows 2 columns - Representative cards show 2 per row - Hero banner uses mobile padding but desktop font size - Maintains visual hierarchy without overwhelming narrow viewports

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#keyboard-navigation","title":"Keyboard Navigation","text":"

Interactive Elements: - All buttons and links focusable via Tab key - Postal code input supports Enter key submission - Card hover states also apply on keyboard focus

Focus Management:

<Input\n  onPressEnter={handlePostalCodeLookup}\n  // Focus indicator via Ant Design theme\n/>\n

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#aria-labels","title":"ARIA Labels","text":"

Representative Photos:

<img\n  src={rep.photo_url || '/default-avatar.png'}\n  alt={`Photo of ${rep.name}, ${rep.elected_office} for ${rep.district_name}`}\n  // Descriptive alt text for screen readers\n/>\n

Loading States:

<Spin size=\"small\" aria-label=\"Loading representatives\" />\n

Icon Buttons:

<Button\n  icon={<SearchOutlined />}\n  aria-label=\"Search for representatives\"\n>\n  Find Representatives\n</Button>\n

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#screen-reader-support","title":"Screen Reader Support","text":"

Structural Headings: - Page uses semantic heading hierarchy (h1 \u2192 h2 \u2192 h3 \u2192 h4) - Hero uses <Title level={1}> for main page title - Sections use <Title level={2}> for logical grouping

Empty States: - Informative messages for \"No campaigns\" and \"No representatives found\" - Visual icons paired with text labels

Statistics:

<Text strong>{campaign.emailsSentCount}</Text>\n<br />\n<Text type=\"secondary\">Emails Sent</Text>\n// Screen reader announces: \"1247 Emails Sent\"\n

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#color-contrast","title":"Color Contrast","text":"

Dark Theme Compliance: - Background #0d1b2a with white text meets WCAG AA (7.8:1 ratio) - Links use #1890ff with sufficient contrast (4.6:1 ratio) - Tag colors (blue, purple, gold) all meet AA standards

Interactive States: - Hover effects use opacity changes (accessible to screen readers) - Focus states use browser default outline (visible on all elements)

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#issue-representatives-not-loading","title":"Issue: Representatives Not Loading","text":"

Symptoms: - Postal code input shows no results - Console shows 404 or 500 error - Loading spinner stuck

Causes: 1. Invalid postal code format (must be Canadian: A1A 1A1) 2. Represent API rate limiting (429 response) 3. Redis cache connection failure 4. Network timeout

Solutions:

// Add postal code validation\nconst isValidPostalCode = (code: string) => {\n  const regex = /^[A-Z]\\d[A-Z]\\s?\\d[A-Z]\\d$/i;\n  return regex.test(code);\n};\n\nconst handlePostalCodeLookup = async () => {\n  const cleanCode = postalCode.trim().toUpperCase();\n\n  if (!isValidPostalCode(cleanCode)) {\n    message.error('Please enter a valid Canadian postal code (e.g., K1A 0B1)');\n    return;\n  }\n\n  try {\n    setRepsLoading(true);\n    const response = await axios.get('/api/public/representatives/lookup', {\n      params: { postalCode: cleanCode },\n      timeout: 10000 // 10s timeout\n    });\n\n    setRepresentatives(response.data);\n  } catch (error: any) {\n    if (error.code === 'ECONNABORTED') {\n      message.error('Request timed out. Please try again.');\n    } else if (error.response?.status === 429) {\n      message.error('Too many requests. Please wait a moment and try again.');\n    } else {\n      message.error('Failed to find representatives. Please try again later.');\n    }\n    console.error('Lookup error:', error);\n  } finally {\n    setRepsLoading(false);\n  }\n};\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#issue-cover-photos-not-displaying","title":"Issue: Cover Photos Not Displaying","text":"

Symptoms: - Campaign cards show gradient instead of uploaded photos - Console shows CORS errors - Broken image icons

Causes: 1. Invalid image URL in database 2. CORS policy blocking external images 3. Image file deleted from storage 4. Incorrect Nginx configuration

Solutions:

// Add image error handling\nconst [imageErrors, setImageErrors] = useState<Set<string>>(new Set());\n\nconst handleImageError = (campaignId: string) => {\n  setImageErrors(prev => new Set(prev).add(campaignId));\n};\n\n// In card cover render:\ncover={\n  <div style={{ height: 200, overflow: 'hidden' }}>\n    {campaign.coverPhoto && !imageErrors.has(campaign.id) ? (\n      <img\n        src={campaign.coverPhoto}\n        alt={campaign.title}\n        onError={() => handleImageError(campaign.id)}\n        style={{\n          width: '100%',\n          height: '100%',\n          objectFit: 'cover'\n        }}\n      />\n    ) : (\n      <div style={{\n        width: '100%',\n        height: '100%',\n        background: 'linear-gradient(135deg, #3498db 0%, #8e44ad 100%)'\n      }} />\n    )}\n  </div>\n}\n

Check Nginx configuration:

# In nginx/conf.d/default.conf\nlocation /uploads/ {\n    add_header Access-Control-Allow-Origin *;\n    add_header Access-Control-Allow-Methods \"GET, OPTIONS\";\n}\n

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#issue-featured-campaign-not-appearing-first","title":"Issue: Featured Campaign Not Appearing First","text":"

Symptoms: - Featured campaign appears in middle/end of grid - Gold border not visible - Star icon missing

Causes: 1. isFeatured flag not set in database 2. Multiple campaigns marked as featured 3. Grid rendering logic error

Solutions:

// Add debug logging\nuseEffect(() => {\n  if (campaigns.length > 0) {\n    const featured = campaigns.filter(c => c.isFeatured);\n    console.log(`Found ${featured.length} featured campaigns:`, featured);\n\n    if (featured.length > 1) {\n      console.warn('Multiple campaigns marked as featured! Only first will display.');\n    }\n  }\n}, [campaigns]);\n\n// Ensure only one featured campaign\nconst featuredCampaign = campaigns.find(c => c.isFeatured);\nconst regularCampaigns = campaigns.filter(c => !c.isFeatured);\n\n// Render in correct order\n<Row gutter={[24, 24]}>\n  {featuredCampaign && (\n    <Col span={24} key={featuredCampaign.id}>\n      {/* Featured card */}\n    </Col>\n  )}\n\n  {regularCampaigns.map((campaign) => (\n    <Col xs={24} sm={12} lg={8} key={campaign.id}>\n      {/* Regular card */}\n    </Col>\n  ))}\n</Row>\n

Check database:

-- Find all featured campaigns\nSELECT id, title, \"isFeatured\"\nFROM \"Campaign\"\nWHERE \"isFeatured\" = true\nAND \"isActive\" = true;\n\n-- Fix multiple featured campaigns (keep most recent)\nUPDATE \"Campaign\"\nSET \"isFeatured\" = false\nWHERE \"isFeatured\" = true\nAND id != (\n  SELECT id\n  FROM \"Campaign\"\n  WHERE \"isFeatured\" = true\n  ORDER BY \"updatedAt\" DESC\n  LIMIT 1\n);\n

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#issue-sharebuttons-not-working","title":"Issue: ShareButtons Not Working","text":"

Symptoms: - Clicking share icons does nothing - \"Copy Link\" doesn't copy to clipboard - No new windows opening

Causes: 1. Popup blockers preventing window.open() 2. Clipboard API not available (non-HTTPS) 3. ShareButtons component not imported 4. Missing event handlers

Solutions:

// Ensure HTTPS for clipboard API\nif (!navigator.clipboard) {\n  console.warn('Clipboard API requires HTTPS');\n  // Fallback to textarea copy method\n}\n\n// Add user interaction check for popups\nconst handleShare = (platform: string) => {\n  // Must be triggered by user action (not async callback)\n  const url = encodeURIComponent(window.location.href);\n  const title = encodeURIComponent('Check out these advocacy campaigns!');\n\n  let shareUrl = '';\n  switch (platform) {\n    case 'twitter':\n      shareUrl = `https://twitter.com/intent/tweet?url=${url}&text=${title}`;\n      break;\n    case 'facebook':\n      shareUrl = `https://www.facebook.com/sharer/sharer.php?u=${url}`;\n      break;\n    // ... other platforms\n  }\n\n  const popup = window.open(shareUrl, '_blank', 'width=600,height=400');\n  if (!popup) {\n    message.warning('Please allow popups to share on social media');\n  }\n};\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#issue-page-loading-very-slowly","title":"Issue: Page Loading Very Slowly","text":"

Symptoms: - Spinner shows for 5+ seconds - Network tab shows slow API responses - Images take long to load

Causes: 1. Large campaign list (100+ campaigns) 2. High-resolution cover photos (5MB+ files) 3. No database indexes on isActive column 4. N+1 query problem (not in this case, single query)

Solutions:

Add pagination (API change required):

const [page, setPage] = useState(1);\nconst [total, setTotal] = useState(0);\nconst pageSize = 12;\n\nuseEffect(() => {\n  const fetchCampaigns = async () => {\n    try {\n      setLoading(true);\n      const response = await axios.get('/api/public/campaigns', {\n        params: { page, limit: pageSize }\n      });\n      setCampaigns(response.data.campaigns);\n      setTotal(response.data.total);\n    } catch (error) {\n      console.error('Failed to fetch campaigns:', error);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  fetchCampaigns();\n}, [page]);\n\n// Add Pagination component\n<Pagination\n  current={page}\n  total={total}\n  pageSize={pageSize}\n  onChange={setPage}\n  style={{ marginTop: 24, textAlign: 'center' }}\n/>\n

Optimize images server-side:

# Add image resizing in upload pipeline\n# Max width: 1200px, quality: 80%\nconvert input.jpg -resize 1200x -quality 80 output.jpg\n

Add database index:

CREATE INDEX idx_campaign_active_featured\nON \"Campaign\" (\"isActive\", \"isFeatured\", \"updatedAt\" DESC);\n

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#public-pages","title":"Public Pages","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#admin-pages","title":"Admin Pages","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#components","title":"Components","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#api-documentation","title":"API Documentation","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#architecture","title":"Architecture","text":""},{"location":"v2/frontend/pages/public/landing-page/","title":"Landing Page (Public Page Renderer)","text":""},{"location":"v2/frontend/pages/public/landing-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/public/LandingPage.tsx (68 lines)

Route: /p/:slug

Role Requirements: Public access

Purpose: Simple renderer for admin-authored landing pages created with GrapesJS editor. Fetches page by slug and displays HTML/CSS content with SEO meta tags.

Key Features:

"},{"location":"v2/frontend/pages/public/landing-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/landing-page/#1-seo-meta-tags","title":"1. SEO Meta Tags","text":"
<Helmet>\n  <title>{page.title}</title>\n  <meta name=\"description\" content={page.description || ''} />\n  <meta property=\"og:title\" content={page.title} />\n  <meta property=\"og:description\" content={page.description || ''} />\n  {page.coverImage && <meta property=\"og:image\" content={page.coverImage} />}\n</Helmet>\n
"},{"location":"v2/frontend/pages/public/landing-page/#2-html-rendering","title":"2. HTML Rendering","text":"
<div dangerouslySetInnerHTML={{ __html: page.html }} />\n<style>{page.css}</style>\n
"},{"location":"v2/frontend/pages/public/landing-page/#3-loading-state","title":"3. Loading State","text":"
{loading && (\n  <div style={{ textAlign: 'center', padding: 100 }}>\n    <Spin size=\"large\" />\n  </div>\n)}\n
"},{"location":"v2/frontend/pages/public/landing-page/#4-404-handling","title":"4. 404 Handling","text":"
{!loading && !page && (\n  <Result\n    status=\"404\"\n    title=\"Page Not Found\"\n    subTitle=\"The page you're looking for doesn't exist.\"\n    extra={<Link to=\"/\"><Button type=\"primary\">Go Home</Button></Link>}\n  />\n)}\n
"},{"location":"v2/frontend/pages/public/landing-page/#api-integration","title":"API Integration","text":"
GET /api/public/pages/:slug\n

Response:

{\n  \"slug\": \"welcome\",\n  \"title\": \"Welcome to Our Campaign\",\n  \"description\": \"Join us in making a difference\",\n  \"html\": \"<div><h1>Welcome</h1>...</div>\",\n  \"css\": \"h1 { color: #1890ff; }\",\n  \"coverImage\": \"https://example.com/cover.jpg\",\n  \"isPublished\": true\n}\n

"},{"location":"v2/frontend/pages/public/landing-page/#security-considerations","title":"Security Considerations","text":"

XSS Risk Accepted: - Pages authored by trusted admins only - dangerouslySetInnerHTML allows full HTML/JS - No user-submitted content - Alternative would break GrapesJS output

"},{"location":"v2/frontend/pages/public/landing-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/public/map-page/","title":"Public Map Page","text":""},{"location":"v2/frontend/pages/public/map-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/public/MapPage.tsx (474 lines)

Route: /map

Role Requirements: Public access (no authentication required)

Purpose: Interactive public-facing map displaying campaign locations with color-coded support levels, cut polygons, and multi-unit building support. Provides geographic visualization of campaign activity and volunteer canvass coverage.

Key Features:

Layout: Uses PublicLayout with custom header override (thin, 48px)

"},{"location":"v2/frontend/pages/public/map-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/map-page/#1-thin-header-design","title":"1. Thin Header Design","text":"

Minimal header to maximize map space:

"},{"location":"v2/frontend/pages/public/map-page/#2-color-coded-location-markers","title":"2. Color-Coded Location Markers","text":"

Visual support level indication:

Marker Styling: - Circle radius: 8px - Stroke: White 2px - Fill opacity: 0.8 - Hover: Increased opacity (1.0)

"},{"location":"v2/frontend/pages/public/map-page/#3-multi-unit-building-popups","title":"3. Multi-Unit Building Popups","text":"

Aggregated building display:

Popup Header: - Purple background (#722ed1) - Building address - Total unit count badge

Unit List: - Sorted by unit number (alphanumeric) - Each row: Unit | Support Level | Notes - Color-coded support badges - Scrollable if >10 units - Max height: 300px

Example:

123 Main St [5 units]\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nUnit 101 | Strong Support | Yard sign\nUnit 102 | Undecided | -\nUnit 201 | No Answer | Left flyer\n

"},{"location":"v2/frontend/pages/public/map-page/#4-cut-polygon-overlays","title":"4. Cut Polygon Overlays","text":"

Geographic boundary visualization:

Polygon Rendering: - GeoJSON format from database - Blue stroke (#1890ff) - Semi-transparent fill (opacity: 0.2) - Label at centroid (cut name)

Toggle Controls: - Floating panel (bottom-left, above zoom) - Checkbox per cut - Select All / Deselect All buttons - Collapse/expand panel

Cut Label Styling: - White text with black outline - Always visible (not obscured by fill) - Click cut to toggle visibility

"},{"location":"v2/frontend/pages/public/map-page/#5-viewport-based-loading","title":"5. Viewport-Based Loading","text":"

Performance optimization for large datasets:

Loading Strategy: - Fetch only locations in current map bounds - Trigger on moveend event (pan/zoom complete) - Debounce 800ms to prevent excessive requests - Loading spinner in top-right during fetch

Bounds Calculation:

const bounds = map.getBounds();\nconst params = {\n  minLat: bounds.getSouth(),\n  maxLat: bounds.getNorth(),\n  minLng: bounds.getWest(),\n  maxLng: bounds.getEast()\n};\n

"},{"location":"v2/frontend/pages/public/map-page/#6-geolocation","title":"6. Geolocation","text":"

User position tracking:

Features: - Blue pulsing circle marker at user's position - Accuracy circle (outer ring) - Automatic pan to location on click - \"Locating...\" loading state - Error handling for denied permissions

Geolocate Button: - Floating control (top-right) - Compass icon - Primary color when active - Error message if unavailable

"},{"location":"v2/frontend/pages/public/map-page/#7-fullscreen-mode","title":"7. Fullscreen Mode","text":"

Immersive map experience:

Activation: - Fullscreen button (top-right, below geolocate) - Browser Fullscreen API - Fallback for Safari (webkitRequestFullscreen)

Exit: - ESC key - Exit fullscreen button (shows when active) - Browser native controls

"},{"location":"v2/frontend/pages/public/map-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/public/map-page/#initial-map-view","title":"Initial Map View","text":"
  1. User navigates to /map
  2. PublicLayout renders with thin header
  3. Map initializes at default center/zoom (from settings)
  4. Viewport bounds calculated
  5. API fetches locations within bounds
  6. Circle markers render for each location
  7. Cuts fetched and rendered (all visible by default)
"},{"location":"v2/frontend/pages/public/map-page/#exploring-locations","title":"Exploring Locations","text":"
  1. User pans map to new area
  2. moveend event triggers after 800ms debounce
  3. New viewport bounds calculated
  4. API fetches locations in new bounds
  5. Existing markers cleared
  6. New markers rendered
  7. User clicks marker to view popup
  8. Popup shows address, support level, notes, last visit date
"},{"location":"v2/frontend/pages/public/map-page/#viewing-multi-unit-buildings","title":"Viewing Multi-Unit Buildings","text":"
  1. User clicks purple building marker
  2. Popup opens with building header
  3. Unit list displays sorted units
  4. User scrolls list (if >10 units)
  5. User sees color-coded support levels per unit
  6. User closes popup by clicking outside or X button
"},{"location":"v2/frontend/pages/public/map-page/#using-geolocation","title":"Using Geolocation","text":"
  1. User clicks geolocate button
  2. Browser prompts for location permission
  3. User grants permission
  4. Blue pulsing marker appears at user's position
  5. Map pans to center on user
  6. Accuracy circle shows GPS precision
  7. User can pan away (marker remains visible)
"},{"location":"v2/frontend/pages/public/map-page/#toggling-cut-visibility","title":"Toggling Cut Visibility","text":"
  1. User clicks \"Cut Controls\" button (bottom-left)
  2. Panel expands showing cut checkboxes
  3. User unchecks \"Cut A\"
  4. \"Cut A\" polygon disappears from map
  5. User clicks \"Deselect All\"
  6. All polygons hidden
  7. User clicks \"Select All\"
  8. All polygons re-appear
"},{"location":"v2/frontend/pages/public/map-page/#fullscreen-mode","title":"Fullscreen Mode","text":"
  1. User clicks fullscreen button
  2. Map expands to fill entire screen
  3. Header hidden
  4. Controls remain visible
  5. User explores map at full size
  6. User presses ESC key
  7. Map returns to normal layout
"},{"location":"v2/frontend/pages/public/map-page/#component-structure","title":"Component Structure","text":"
import React, { useState, useEffect, useCallback } from 'react';\nimport { MapContainer, TileLayer, CircleMarker, Popup, useMap, useMapEvents, Polygon } from 'react-leaflet';\nimport { Button, Spin, Checkbox, Space, Typography, Badge } from 'antd';\nimport {\n  AimOutlined,\n  FullscreenOutlined,\n  FullscreenExitOutlined,\n  EnvironmentOutlined\n} from '@ant-design/icons';\nimport { debounce } from 'lodash';\nimport PublicLayout from '../../components/PublicLayout';\nimport axios from 'axios';\nimport 'leaflet/dist/leaflet.css';\n\nconst { Text } = Typography;\n\ninterface Location {\n  id: string;\n  address: string;\n  latitude: number;\n  longitude: number;\n  supportLevel: string | null;\n  notes: string | null;\n  lastVisitDate: string | null;\n  isMultiUnit: boolean;\n  units?: Array<{\n    unitNumber: string;\n    supportLevel: string | null;\n    notes: string | null;\n  }>;\n}\n\ninterface Cut {\n  id: string;\n  name: string;\n  color: string;\n  polygon: any; // GeoJSON\n}\n\nconst MapPage: React.FC = () => {\n  const [locations, setLocations] = useState<Location[]>([]);\n  const [cuts, setCuts] = useState<Cut[]>([]);\n  const [visibleCuts, setVisibleCuts] = useState<Set<string>>(new Set());\n  const [loading, setLoading] = useState(false);\n  const [userPosition, setUserPosition] = useState<[number, number] | null>(null);\n  const [mapCenter, setMapCenter] = useState<[number, number]>([45.5017, -73.5673]);\n  const [mapZoom, setMapZoom] = useState(13);\n\n  // Component logic...\n\n  return (\n    <PublicLayout headerHeight={48}>\n      <MapContainer\n        center={mapCenter}\n        zoom={mapZoom}\n        style={{ height: 'calc(100vh - 48px)', width: '100%' }}\n        zoomControl={false}\n      >\n        <TileLayer\n          url=\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\"\n          attribution='&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a>'\n        />\n\n        {/* Locations */}\n        {/* Cuts */}\n        {/* User Position */}\n        {/* Controls */}\n      </MapContainer>\n    </PublicLayout>\n  );\n};\n
"},{"location":"v2/frontend/pages/public/map-page/#state-management","title":"State Management","text":"
// Location data\nconst [locations, setLocations] = useState<Location[]>([]);\nconst [cuts, setCuts] = useState<Cut[]>([]);\nconst [visibleCuts, setVisibleCuts] = useState<Set<string>>(new Set());\n\n// Map state\nconst [mapCenter, setMapCenter] = useState<[number, number]>([45.5017, -73.5673]);\nconst [mapZoom, setMapZoom] = useState(13);\n\n// User interaction\nconst [loading, setLoading] = useState(false);\nconst [userPosition, setUserPosition] = useState<[number, number] | null>(null);\nconst [fullscreen, setFullscreen] = useState(false);\n
"},{"location":"v2/frontend/pages/public/map-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/public/map-page/#endpoints","title":"Endpoints","text":""},{"location":"v2/frontend/pages/public/map-page/#1-get-locations-by-bounds","title":"1. Get Locations by Bounds","text":"
GET /api/public/map/locations?minLat=45.4&maxLat=45.6&minLng=-73.7&maxLng=-73.4\n

Response:

[\n  {\n    \"id\": \"cm1abc123\",\n    \"address\": \"123 Main St\",\n    \"latitude\": 45.5017,\n    \"longitude\": -73.5673,\n    \"supportLevel\": \"strong_support\",\n    \"notes\": \"Yard sign requested\",\n    \"lastVisitDate\": \"2025-02-10T14:00:00.000Z\",\n    \"isMultiUnit\": false\n  }\n]\n

"},{"location":"v2/frontend/pages/public/map-page/#2-get-cuts","title":"2. Get Cuts","text":"
GET /api/public/map/cuts\n

Response:

[\n  {\n    \"id\": \"cm2def456\",\n    \"name\": \"Downtown District\",\n    \"color\": \"#1890ff\",\n    \"polygon\": {\n      \"type\": \"Polygon\",\n      \"coordinates\": [[[-73.6, 45.5], [-73.5, 45.5], [-73.5, 45.6], [-73.6, 45.6], [-73.6, 45.5]]]\n    }\n  }\n]\n

"},{"location":"v2/frontend/pages/public/map-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/public/map-page/#viewport-based-loading-with-debounce","title":"Viewport-Based Loading with Debounce","text":"
const MapEventsHandler = () => {\n  const map = useMap();\n\n  const fetchLocationsInBounds = useCallback(async () => {\n    const bounds = map.getBounds();\n    setLoading(true);\n\n    try {\n      const response = await axios.get('/api/public/map/locations', {\n        params: {\n          minLat: bounds.getSouth(),\n          maxLat: bounds.getNorth(),\n          minLng: bounds.getWest(),\n          maxLng: bounds.getEast()\n        }\n      });\n      setLocations(response.data);\n    } catch (error) {\n      console.error('Failed to fetch locations:', error);\n    } finally {\n      setLoading(false);\n    }\n  }, [map]);\n\n  const debouncedFetch = useCallback(\n    debounce(fetchLocationsInBounds, 800),\n    [fetchLocationsInBounds]\n  );\n\n  useMapEvents({\n    moveend: debouncedFetch\n  });\n\n  return null;\n};\n
"},{"location":"v2/frontend/pages/public/map-page/#color-coded-location-markers","title":"Color-Coded Location Markers","text":"
const getSupportLevelColor = (level: string | null): string => {\n  switch (level) {\n    case 'strong_support': return '#52c41a';\n    case 'leaning_support': return '#95de64';\n    case 'undecided': return '#fadb14';\n    case 'leaning_opposed': return '#ff7a45';\n    case 'opposed': return '#f5222d';\n    case 'no_answer': return '#8c8c8c';\n    case 'not_home': return '#d9d9d9';\n    default: return '#8c8c8c';\n  }\n};\n\n{locations.map(location => (\n  <CircleMarker\n    key={location.id}\n    center={[location.latitude, location.longitude]}\n    radius={8}\n    pathOptions={{\n      color: 'white',\n      weight: 2,\n      fillColor: getSupportLevelColor(location.supportLevel),\n      fillOpacity: 0.8\n    }}\n  >\n    <Popup>\n      <div style={{ minWidth: 200 }}>\n        <Text strong style={{ display: 'block', marginBottom: 8 }}>\n          {location.address}\n        </Text>\n        {location.supportLevel && (\n          <Text>Support: {location.supportLevel.replace('_', ' ')}</Text>\n        )}\n        {location.notes && (\n          <Text type=\"secondary\" style={{ display: 'block', marginTop: 4, fontSize: 12 }}>\n            {location.notes}\n          </Text>\n        )}\n      </div>\n    </Popup>\n  </CircleMarker>\n))}\n
"},{"location":"v2/frontend/pages/public/map-page/#multi-unit-building-popup","title":"Multi-Unit Building Popup","text":"
{location.isMultiUnit && location.units && (\n  <Popup>\n    <div style={{ minWidth: 300, maxHeight: 400, overflow: 'auto' }}>\n      <div style={{\n        background: '#722ed1',\n        color: 'white',\n        padding: 12,\n        margin: -12,\n        marginBottom: 12\n      }}>\n        <Text strong style={{ color: 'white', fontSize: 16 }}>\n          {location.address}\n        </Text>\n        <Badge\n          count={location.units.length}\n          style={{ marginLeft: 8, background: 'white', color: '#722ed1' }}\n        />\n      </div>\n\n      <table style={{ width: '100%', fontSize: 12 }}>\n        <thead>\n          <tr style={{ borderBottom: '1px solid #f0f0f0' }}>\n            <th style={{ textAlign: 'left', padding: 4 }}>Unit</th>\n            <th style={{ textAlign: 'left', padding: 4 }}>Support</th>\n            <th style={{ textAlign: 'left', padding: 4 }}>Notes</th>\n          </tr>\n        </thead>\n        <tbody>\n          {location.units\n            .sort((a, b) => a.unitNumber.localeCompare(b.unitNumber, undefined, { numeric: true }))\n            .map((unit, idx) => (\n              <tr key={idx} style={{ borderBottom: '1px solid #f5f5f5' }}>\n                <td style={{ padding: 4 }}>{unit.unitNumber}</td>\n                <td style={{ padding: 4 }}>\n                  <span style={{\n                    background: getSupportLevelColor(unit.supportLevel),\n                    color: 'white',\n                    padding: '2px 6px',\n                    borderRadius: 3,\n                    fontSize: 11\n                  }}>\n                    {unit.supportLevel?.replace('_', ' ') || '-'}\n                  </span>\n                </td>\n                <td style={{ padding: 4, fontSize: 11, color: '#666' }}>\n                  {unit.notes || '-'}\n                </td>\n              </tr>\n            ))}\n        </tbody>\n      </table>\n    </div>\n  </Popup>\n)}\n
"},{"location":"v2/frontend/pages/public/map-page/#performance-considerations","title":"Performance Considerations","text":"
  1. Debounced Loading: 800ms debounce prevents excessive API calls during panning
  2. Viewport Filtering: Only loads visible locations (scalable to 10,000+ locations)
  3. React-Leaflet Optimization: Uses key prop to prevent unnecessary re-renders
  4. Lazy Popup Rendering: Popups created on-demand, not upfront
"},{"location":"v2/frontend/pages/public/map-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/public/map-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/public/map-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/public/map-page/#issue-markers-not-appearing","title":"Issue: Markers Not Appearing","text":"

Causes: 1. Locations outside viewport bounds 2. API returning empty array 3. Leaflet CSS not imported

Solutions:

import 'leaflet/dist/leaflet.css'; // Must be imported\n\n// Add debug logging\nuseEffect(() => {\n  console.log(`Loaded ${locations.length} locations`);\n}, [locations]);\n

"},{"location":"v2/frontend/pages/public/map-page/#issue-geolocation-not-working","title":"Issue: Geolocation Not Working","text":"

Causes: 1. HTTPS required for geolocation API 2. User denied permission 3. Browser doesn't support geolocation

Solutions:

const handleGeolocate = () => {\n  if (!navigator.geolocation) {\n    message.error('Geolocation not supported by your browser');\n    return;\n  }\n\n  navigator.geolocation.getCurrentPosition(\n    (position) => {\n      const pos: [number, number] = [\n        position.coords.latitude,\n        position.coords.longitude\n      ];\n      setUserPosition(pos);\n      map.flyTo(pos, 16);\n    },\n    (error) => {\n      if (error.code === error.PERMISSION_DENIED) {\n        message.error('Location permission denied');\n      } else {\n        message.error('Unable to get your location');\n      }\n    }\n  );\n};\n

"},{"location":"v2/frontend/pages/public/map-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/public/media-gallery-page/","title":"Media Gallery Page","text":""},{"location":"v2/frontend/pages/public/media-gallery-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/public/MediaGalleryPage.tsx (195 lines)

Route: /media (with optional ?category=X query param)

Role Requirements: Public access

Purpose: Public-facing video gallery displaying shared media content with search, sort, category filtering, and pagination.

Key Features:

Layout: Uses MediaPublicLayout (specialized public layout for media)

"},{"location":"v2/frontend/pages/public/media-gallery-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/media-gallery-page/#1-search-bar","title":"1. Search Bar","text":"
<Input.Search\n  placeholder=\"Search videos...\"\n  size=\"large\"\n  onChange={(e) => {\n    clearTimeout(searchDebounce);\n    searchDebounce = setTimeout(() => {\n      setSearchTerm(e.target.value);\n      setPage(1);\n    }, 300);\n  }}\n  style={{ maxWidth: 500 }}\n/>\n
"},{"location":"v2/frontend/pages/public/media-gallery-page/#2-sort-dropdown","title":"2. Sort Dropdown","text":"

Options: - Recent: createdAt DESC - Popular: reactionCount DESC - Most Viewed: viewCount DESC

"},{"location":"v2/frontend/pages/public/media-gallery-page/#3-video-grid","title":"3. Video Grid","text":"
<Row gutter={[16, 16]}>\n  {videos.map(video => (\n    <Col xs={24} sm={12} md={8} lg={6} key={video.id}>\n      <PublicVideoCard video={video} />\n    </Col>\n  ))}\n</Row>\n
"},{"location":"v2/frontend/pages/public/media-gallery-page/#4-category-filter","title":"4. Category Filter","text":"

URL-based filtering: - /media - All categories - /media?category=testimonials - Testimonials only - /media?category=events - Events only

"},{"location":"v2/frontend/pages/public/media-gallery-page/#api-integration","title":"API Integration","text":"
GET /api/media/public?page=1&limit=24&search=climate&sort=recent&category=testimonials\n

Response:

{\n  \"videos\": [\n    {\n      \"id\": \"vid123\",\n      \"title\": \"Climate Rally Highlights\",\n      \"thumbnailUrl\": \"/media/thumbnails/vid123.jpg\",\n      \"duration\": 245,\n      \"viewCount\": 1523,\n      \"upvotes\": 87,\n      \"category\": \"events\"\n    }\n  ],\n  \"total\": 156,\n  \"page\": 1,\n  \"limit\": 24\n}\n

"},{"location":"v2/frontend/pages/public/media-gallery-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/public/media-viewer-page/","title":"Media Viewer Page","text":""},{"location":"v2/frontend/pages/public/media-viewer-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/public/MediaViewerPage.tsx (306 lines)

Route: /media/:id

Role Requirements: Public access (locked videos require login)

Purpose: Individual video player page with metadata, reactions, comments, and related videos.

Key Features:

"},{"location":"v2/frontend/pages/public/media-viewer-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/media-viewer-page/#1-video-player","title":"1. Video Player","text":"
<VideoPlayer\n  videoUrl={video.videoUrl}\n  onTimeUpdate={(currentTime) => {\n    // Track view progress\n    if (currentTime > lastTrackedTime + 30) {\n      trackView(video.id, currentTime);\n      setLastTrackedTime(currentTime);\n    }\n  }}\n/>\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#2-metadata-display","title":"2. Metadata Display","text":"
<Space size={16}>\n  <Text type=\"secondary\">\n    <EyeOutlined /> {video.viewCount} views\n  </Text>\n  <Text type=\"secondary\">\n    <LikeOutlined /> {video.upvotes} upvotes\n  </Text>\n  <Tag color=\"blue\">{video.category}</Tag>\n  {video.quality && <Tag color=\"green\">{video.quality}p</Tag>}\n</Space>\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#3-upvote-button","title":"3. Upvote Button","text":"
<Button\n  type={hasUpvoted ? 'primary' : 'default'}\n  icon={hasUpvoted ? <LikeFilled /> : <LikeOutlined />}\n  onClick={handleUpvote}\n  size=\"large\"\n>\n  Upvote ({video.upvotes})\n</Button>\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#4-reaction-buttons","title":"4. Reaction Buttons","text":"

6 emoji reactions: - \ud83d\udc4d Like - \u2764\ufe0f Love - \ud83d\ude02 Haha - \ud83d\ude2e Wow - \ud83d\ude22 Sad - \ud83d\ude21 Angry

<ReactionButtons\n  videoId={video.id}\n  reactions={video.reactions}\n  onReact={handleReact}\n/>\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#5-comment-section","title":"5. Comment Section","text":"
<CommentSection\n  videoId={video.id}\n  comments={comments}\n  onSubmit={handleCommentSubmit}\n/>\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#6-related-videos","title":"6. Related Videos","text":"
<Title level={4}>Related Videos</Title>\n<Row gutter={[16, 16]}>\n  {relatedVideos.slice(0, 3).map(video => (\n    <Col xs={24} sm={8} key={video.id}>\n      <PublicVideoCard video={video} />\n    </Col>\n  ))}\n</Row>\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#7-locked-video-handling","title":"7. Locked Video Handling","text":"
{video.isLocked && !user && (\n  <Modal\n    title=\"Login Required\"\n    open={true}\n    footer={\n      <Button type=\"primary\" onClick={() => navigate('/login')}>\n        Go to Login\n      </Button>\n    }\n  >\n    This video requires login to view.\n  </Modal>\n)}\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/public/media-viewer-page/#endpoints","title":"Endpoints","text":""},{"location":"v2/frontend/pages/public/media-viewer-page/#1-get-video","title":"1. Get Video","text":"
GET /api/media/public/:id\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#2-track-view","title":"2. Track View","text":"
POST /api/media/public/:id/view\nContent-Type: application/json\n\n{\n  \"currentTime\": 67.5\n}\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#3-toggle-upvote","title":"3. Toggle Upvote","text":"
POST /api/media/public/:id/upvote\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#4-add-reaction","title":"4. Add Reaction","text":"
POST /api/media/public/:id/react\nContent-Type: application/json\n\n{\n  \"reactionType\": \"love\"\n}\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#performance-considerations","title":"Performance Considerations","text":"
  1. View Tracking: Throttled to 30-second intervals
  2. Related Videos: Limited to 3 (prevents over-fetching)
  3. Lazy Comments: Loaded separately after video metadata
  4. Video Preload: preload=\"metadata\" for faster initial render
"},{"location":"v2/frontend/pages/public/media-viewer-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/public/media-viewer-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/public/response-wall-page/","title":"Response Wall Page","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/public/ResponseWallPage.tsx (492 lines)

Route: /responses/:campaignId

Role Requirements: Public access (no authentication required)

Purpose: Community-driven response wall displaying user-submitted campaign feedback, verification status, government official replies, and social engagement through upvoting. Serves as social proof and community building tool for advocacy campaigns.

Key Features:

Layout: Uses PublicLayout component with dark theme

"},{"location":"v2/frontend/pages/public/response-wall-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#1-campaign-context-header","title":"1. Campaign Context Header","text":"

Navigation and campaign identification:

"},{"location":"v2/frontend/pages/public/response-wall-page/#2-statistics-dashboard","title":"2. Statistics Dashboard","text":"

Three key metrics displayed as cards:

Card Design: - Large numeric display (32px font) - Icon with brand color - Label text below number - Responsive grid (xs=1, sm=3 columns) - Hover effect for visual feedback

"},{"location":"v2/frontend/pages/public/response-wall-page/#3-filtering-and-sorting-controls","title":"3. Filtering and Sorting Controls","text":"

User controls for response discovery:

Sort Dropdown: - Recent: Newest first (default, createdAt DESC) - Most Upvoted: Highest upvote count first (upvoteCount DESC) - Verified Only: Only email-verified responses

Government Level Filter: - All Levels: No filtering (default) - Federal: Federal government responses only - Provincial: Provincial/territorial responses only - Municipal: Municipal/local responses only

Layout: - Row with two columns - Sort on left, filter on right - Full-width selects on mobile - Margin below for spacing

"},{"location":"v2/frontend/pages/public/response-wall-page/#4-response-cards","title":"4. Response Cards","text":"

Individual response display with rich metadata:

Card Header: - User name (bold, 16px) - Timestamp (relative: \"2 hours ago\") - Verification badge (if isVerified=true)

Card Content: - User comment (full text, auto-wrapping) - Quoted text if available (italicized, gray background) - Representative details: - Name (bold) - District/riding - Government level tag (colored by level)

Card Footer: - Upvote button with count - Heart icon (filled if user upvoted) - Click toggles upvote status - Optimistic UI update

Styling: - Dark background (colorBgContainer) - Rounded corners (8px) - Hover elevation shadow - Dividers between sections

"},{"location":"v2/frontend/pages/public/response-wall-page/#5-submit-response-modal","title":"5. Submit Response Modal","text":"

Long-form response submission interface:

Form Fields: - Your Name (required, text input) - Your Email (required, email validation) - Your Postal Code (optional, for rep lookup context) - Representative (read-only, from parent campaign context) - Your Comment (required, TextArea, 5 rows min) - Email Me a Copy (checkbox, default checked)

Validation: - Required field indicators - Email format validation - Min/max length checks (comment: 10-5000 chars) - Disabled submit until valid

Submission Flow: 1. User clicks \"Submit Your Response\" button 2. Modal opens with empty form 3. User fills fields 4. Clicks \"Submit Response\" button 5. API creates response (status: unverified) 6. Verification email sent if checkbox checked 7. Success modal displays 8. Form resets 9. Responses list refreshes

"},{"location":"v2/frontend/pages/public/response-wall-page/#6-pagination","title":"6. Pagination","text":"

Ant Design Pagination component:

"},{"location":"v2/frontend/pages/public/response-wall-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#browsing-responses","title":"Browsing Responses","text":"
  1. User arrives from campaign page via \"View Response Wall\" link
  2. Page loads responses (default: recent, all levels)
  3. User views statistics cards showing community engagement
  4. User scrolls through response cards
  5. User reads comments and representative details
  6. User upvotes responses they agree with
  7. User clicks pagination to view more responses
"},{"location":"v2/frontend/pages/public/response-wall-page/#filtering-and-sorting","title":"Filtering and Sorting","text":"
  1. User selects \"Most Upvoted\" from sort dropdown
  2. API re-fetches responses with new sort order
  3. Grid updates with reordered responses
  4. User selects \"Federal\" from government level filter
  5. API re-fetches with government level filter
  6. Grid shows only federal responses
  7. User resets filters to \"All Levels\" to see everything
"},{"location":"v2/frontend/pages/public/response-wall-page/#submitting-a-response","title":"Submitting a Response","text":"
  1. User clicks \"Submit Your Response\" button
  2. Modal opens with blank form
  3. User enters name: \"Jane Doe\"
  4. User enters email: \"jane@example.com\"
  5. User enters postal code: \"K1A 0B1\" (optional)
  6. User writes comment: \"I strongly support this bill because...\"
  7. User checks \"Email me a copy\" checkbox
  8. User clicks \"Submit Response\"
  9. API creates response with isVerified=false
  10. Backend sends verification email to jane@example.com
  11. Success modal displays: \"Response submitted! Check your email to verify.\"
  12. User clicks \"OK\"
  13. Modal closes
  14. Responses grid refreshes (may not show new response if \"Verified Only\" filter active)
"},{"location":"v2/frontend/pages/public/response-wall-page/#upvoting","title":"Upvoting","text":"
  1. User sees response they agree with
  2. User clicks heart icon button
  3. Optimistic update: upvote count increments, heart fills with color
  4. API request to /api/public/responses/:id/upvote
  5. If API succeeds: update persists
  6. If API fails: revert to previous state, show error message
  7. User can click again to remove upvote (toggle behavior)
"},{"location":"v2/frontend/pages/public/response-wall-page/#component-structure","title":"Component Structure","text":"
import React, { useState, useEffect } from 'react';\nimport { useParams, Link } from 'react-router-dom';\nimport {\n  Card,\n  Row,\n  Col,\n  Typography,\n  Button,\n  Select,\n  Statistic,\n  Modal,\n  Form,\n  Input,\n  Checkbox,\n  Pagination,\n  Tag,\n  Space,\n  message,\n  Grid\n} from 'antd';\nimport {\n  ArrowLeftOutlined,\n  CommentOutlined,\n  HeartOutlined,\n  HeartFilled,\n  CheckCircleOutlined,\n  TrophyOutlined,\n  FireOutlined\n} from '@ant-design/icons';\nimport dayjs from 'dayjs';\nimport relativeTime from 'dayjs/plugin/relativeTime';\nimport PublicLayout from '../../components/PublicLayout';\nimport axios from 'axios';\n\ndayjs.extend(relativeTime);\n\nconst { Title, Paragraph, Text } = Typography;\nconst { TextArea } = Input;\nconst { Option } = Select;\nconst { useBreakpoint } = Grid;\n\ninterface Response {\n  id: string;\n  userName: string;\n  userEmail: string;\n  postalCode: string | null;\n  comment: string;\n  quotedText: string | null;\n  isVerified: boolean;\n  upvoteCount: number;\n  representativeName: string;\n  representativeDistrict: string;\n  governmentLevel: string;\n  createdAt: string;\n  hasUpvoted?: boolean; // Client-side tracking\n}\n\ninterface Campaign {\n  id: string;\n  title: string;\n}\n\ninterface Stats {\n  totalResponses: number;\n  verifiedResponses: number;\n  totalUpvotes: number;\n}\n\nconst ResponseWallPage: React.FC = () => {\n  const { campaignId } = useParams<{ campaignId: string }>();\n  const [responses, setResponses] = useState<Response[]>([]);\n  const [campaign, setCampaign] = useState<Campaign | null>(null);\n  const [stats, setStats] = useState<Stats>({ totalResponses: 0, verifiedResponses: 0, totalUpvotes: 0 });\n  const [loading, setLoading] = useState(true);\n  const [sortBy, setSortBy] = useState<string>('recent');\n  const [governmentLevel, setGovernmentLevel] = useState<string>('all');\n  const [page, setPage] = useState(1);\n  const [total, setTotal] = useState(0);\n  const [submitModalVisible, setSubmitModalVisible] = useState(false);\n  const [form] = Form.useForm();\n  const screens = useBreakpoint();\n  const isMobile = !screens.md;\n\n  const pageSize = 20;\n\n  // Data fetching, handlers, etc.\n\n  return (\n    <PublicLayout>\n      {/* Back link and title */}\n      {/* Statistics cards */}\n      {/* Sort and filter controls */}\n      {/* Response cards grid */}\n      {/* Pagination */}\n      {/* Submit modal */}\n    </PublicLayout>\n  );\n};\n\nexport default ResponseWallPage;\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#component-state","title":"Component State","text":"
// Response data\nconst [responses, setResponses] = useState<Response[]>([]);\nconst [campaign, setCampaign] = useState<Campaign | null>(null);\nconst [stats, setStats] = useState<Stats>({\n  totalResponses: 0,\n  verifiedResponses: 0,\n  totalUpvotes: 0\n});\nconst [loading, setLoading] = useState(true);\n\n// Filtering and sorting\nconst [sortBy, setSortBy] = useState<string>('recent'); // 'recent' | 'upvotes' | 'verified'\nconst [governmentLevel, setGovernmentLevel] = useState<string>('all'); // 'all' | 'federal' | 'provincial' | 'municipal'\n\n// Pagination\nconst [page, setPage] = useState(1);\nconst [total, setTotal] = useState(0);\nconst pageSize = 20;\n\n// Modal state\nconst [submitModalVisible, setSubmitModalVisible] = useState(false);\nconst [form] = Form.useForm();\n\n// Responsive\nconst screens = useBreakpoint();\nconst isMobile = !screens.md;\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#derived-state","title":"Derived State","text":"
// No complex derived state - filtering happens server-side\n// All data transformations done by API\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#state-flow","title":"State Flow","text":"
  1. Initial Load: loading=true, fetch campaign + responses + stats
  2. Data Received: setCampaign(), setResponses(), setStats(), setTotal(), loading=false
  3. Sort Changed: setSortBy(), setPage(1), refetch responses
  4. Filter Changed: setGovernmentLevel(), setPage(1), refetch responses
  5. Page Changed: setPage(), refetch responses (keep sort/filter)
  6. Upvote Clicked: Optimistic update to responses array, API call
  7. Submit Clicked: setSubmitModalVisible(true), open form
  8. Response Submitted: API call, setSubmitModalVisible(false), refetch responses
"},{"location":"v2/frontend/pages/public/response-wall-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#endpoints-used","title":"Endpoints Used","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#1-get-campaign-basic-info","title":"1. Get Campaign (Basic Info)","text":"
GET /api/public/campaigns/:campaignId\n

Response:

{\n  \"id\": \"cm1abc123\",\n  \"title\": \"Support Climate Action Bill\"\n}\n

"},{"location":"v2/frontend/pages/public/response-wall-page/#2-get-response-statistics","title":"2. Get Response Statistics","text":"
GET /api/public/responses/campaigns/:campaignId/stats\n

Response:

{\n  \"totalResponses\": 342,\n  \"verifiedResponses\": 287,\n  \"totalUpvotes\": 1829\n}\n

"},{"location":"v2/frontend/pages/public/response-wall-page/#3-list-responses","title":"3. List Responses","text":"
GET /api/public/responses/campaigns/:campaignId?page=1&limit=20&sortBy=recent&governmentLevel=all\n

Query Parameters: - page: Page number (1-indexed) - limit: Items per page (default 20, max 100) - sortBy: recent | upvotes | verified - governmentLevel: all | federal | provincial | municipal

Response:

{\n  \"responses\": [\n    {\n      \"id\": \"cm2abc123\",\n      \"userName\": \"Jane Doe\",\n      \"userEmail\": \"jane@example.com\",\n      \"postalCode\": \"K1A 0B1\",\n      \"comment\": \"I strongly support this bill because it addresses critical climate issues...\",\n      \"quotedText\": null,\n      \"isVerified\": true,\n      \"upvoteCount\": 47,\n      \"representativeName\": \"John Smith\",\n      \"representativeDistrict\": \"Ottawa Centre\",\n      \"governmentLevel\": \"federal\",\n      \"createdAt\": \"2025-02-10T14:30:00.000Z\"\n    }\n  ],\n  \"total\": 342,\n  \"page\": 1,\n  \"limit\": 20\n}\n

"},{"location":"v2/frontend/pages/public/response-wall-page/#4-submit-response","title":"4. Submit Response","text":"
POST /api/public/responses\nContent-Type: application/json\n\n{\n  \"campaignId\": \"cm1abc123\",\n  \"userName\": \"Jane Doe\",\n  \"userEmail\": \"jane@example.com\",\n  \"postalCode\": \"K1A 0B1\",\n  \"comment\": \"I strongly support this bill...\",\n  \"representativeName\": \"John Smith\",\n  \"representativeDistrict\": \"Ottawa Centre\",\n  \"governmentLevel\": \"federal\",\n  \"sendCopy\": true\n}\n

Response:

{\n  \"success\": true,\n  \"responseId\": \"cm2def456\",\n  \"message\": \"Response submitted successfully. Please check your email to verify.\"\n}\n

"},{"location":"v2/frontend/pages/public/response-wall-page/#5-upvote-response","title":"5. Upvote Response","text":"
POST /api/public/responses/:id/upvote\n

Response:

{\n  \"success\": true,\n  \"upvoteCount\": 48,\n  \"action\": \"added\"\n}\n

Note: Second request to same endpoint toggles (removes upvote), returns \"action\": \"removed\".

"},{"location":"v2/frontend/pages/public/response-wall-page/#request-examples","title":"Request Examples","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#fetch-responses","title":"Fetch Responses","text":"
useEffect(() => {\n  const fetchData = async () => {\n    if (!campaignId) return;\n\n    try {\n      setLoading(true);\n\n      const [campaignRes, statsRes, responsesRes] = await Promise.all([\n        axios.get(`/api/public/campaigns/${campaignId}`),\n        axios.get(`/api/public/responses/campaigns/${campaignId}/stats`),\n        axios.get(`/api/public/responses/campaigns/${campaignId}`, {\n          params: {\n            page,\n            limit: pageSize,\n            sortBy,\n            governmentLevel: governmentLevel === 'all' ? undefined : governmentLevel\n          }\n        })\n      ]);\n\n      setCampaign(campaignRes.data);\n      setStats(statsRes.data);\n      setResponses(responsesRes.data.responses);\n      setTotal(responsesRes.data.total);\n\n    } catch (error) {\n      console.error('Failed to fetch data:', error);\n      message.error('Failed to load responses');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  fetchData();\n}, [campaignId, page, sortBy, governmentLevel]);\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#submit-response","title":"Submit Response","text":"
const handleSubmit = async (values: any) => {\n  try {\n    await axios.post('/api/public/responses', {\n      campaignId,\n      userName: values.userName,\n      userEmail: values.userEmail,\n      postalCode: values.postalCode || null,\n      comment: values.comment,\n      representativeName: values.representativeName,\n      representativeDistrict: values.representativeDistrict || '',\n      governmentLevel: values.governmentLevel || 'federal',\n      sendCopy: values.sendCopy\n    });\n\n    Modal.success({\n      title: 'Response Submitted!',\n      content: 'Please check your email to verify your response.',\n    });\n\n    setSubmitModalVisible(false);\n    form.resetFields();\n\n    // Refresh responses list\n    setPage(1);\n    // Triggers useEffect refetch\n\n  } catch (error: any) {\n    console.error('Submit failed:', error);\n    message.error(error.response?.data?.message || 'Failed to submit response');\n  }\n};\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#upvote-response","title":"Upvote Response","text":"
const handleUpvote = async (responseId: string) => {\n  // Optimistic update\n  setResponses(prev => prev.map(r => {\n    if (r.id === responseId) {\n      const hasUpvoted = !r.hasUpvoted;\n      return {\n        ...r,\n        hasUpvoted,\n        upvoteCount: r.upvoteCount + (hasUpvoted ? 1 : -1)\n      };\n    }\n    return r;\n  }));\n\n  try {\n    const response = await axios.post(`/api/public/responses/${responseId}/upvote`);\n\n    // Update with server count (in case of race condition)\n    setResponses(prev => prev.map(r =>\n      r.id === responseId\n        ? { ...r, upvoteCount: response.data.upvoteCount }\n        : r\n    ));\n\n  } catch (error) {\n    console.error('Upvote failed:', error);\n\n    // Revert on error\n    setResponses(prev => prev.map(r => {\n      if (r.id === responseId) {\n        return {\n          ...r,\n          hasUpvoted: !r.hasUpvoted,\n          upvoteCount: r.upvoteCount + (r.hasUpvoted ? -1 : 1)\n        };\n      }\n      return r;\n    }));\n\n    message.error('Failed to upvote. Please try again.');\n  }\n};\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#statistics-cards","title":"Statistics Cards","text":"
<Row gutter={[16, 16]} style={{ marginBottom: 32 }}>\n  <Col xs={24} sm={8}>\n    <Card>\n      <Statistic\n        title=\"Total Responses\"\n        value={stats.totalResponses}\n        prefix={<CommentOutlined style={{ color: '#1890ff' }} />}\n        valueStyle={{ color: '#1890ff', fontSize: 32 }}\n      />\n    </Card>\n  </Col>\n\n  <Col xs={24} sm={8}>\n    <Card>\n      <Statistic\n        title=\"Verified Responses\"\n        value={stats.verifiedResponses}\n        prefix={<CheckCircleOutlined style={{ color: '#52c41a' }} />}\n        valueStyle={{ color: '#52c41a', fontSize: 32 }}\n      />\n    </Card>\n  </Col>\n\n  <Col xs={24} sm={8}>\n    <Card>\n      <Statistic\n        title=\"Total Upvotes\"\n        value={stats.totalUpvotes}\n        prefix={<HeartFilled style={{ color: '#eb2f96' }} />}\n        valueStyle={{ color: '#eb2f96', fontSize: 32 }}\n      />\n    </Card>\n  </Col>\n</Row>\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#sort-and-filter-controls","title":"Sort and Filter Controls","text":"
<Row gutter={16} style={{ marginBottom: 24 }}>\n  <Col xs={24} sm={12}>\n    <Space direction=\"vertical\" style={{ width: '100%' }} size={4}>\n      <Text type=\"secondary\">Sort by:</Text>\n      <Select\n        value={sortBy}\n        onChange={(value) => {\n          setSortBy(value);\n          setPage(1); // Reset to page 1 when sorting changes\n        }}\n        style={{ width: '100%' }}\n        size=\"large\"\n      >\n        <Option value=\"recent\">\n          <FireOutlined /> Recent\n        </Option>\n        <Option value=\"upvotes\">\n          <TrophyOutlined /> Most Upvoted\n        </Option>\n        <Option value=\"verified\">\n          <CheckCircleOutlined /> Verified Only\n        </Option>\n      </Select>\n    </Space>\n  </Col>\n\n  <Col xs={24} sm={12}>\n    <Space direction=\"vertical\" style={{ width: '100%' }} size={4}>\n      <Text type=\"secondary\">Government Level:</Text>\n      <Select\n        value={governmentLevel}\n        onChange={(value) => {\n          setGovernmentLevel(value);\n          setPage(1); // Reset to page 1 when filter changes\n        }}\n        style={{ width: '100%' }}\n        size=\"large\"\n      >\n        <Option value=\"all\">All Levels</Option>\n        <Option value=\"federal\">Federal</Option>\n        <Option value=\"provincial\">Provincial</Option>\n        <Option value=\"municipal\">Municipal</Option>\n      </Select>\n    </Space>\n  </Col>\n</Row>\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#response-cards","title":"Response Cards","text":"
<Row gutter={[16, 16]}>\n  {responses.map((response) => (\n    <Col xs={24} key={response.id}>\n      <Card hoverable>\n        {/* Header */}\n        <div style={{\n          display: 'flex',\n          justifyContent: 'space-between',\n          alignItems: 'center',\n          marginBottom: 16\n        }}>\n          <Space>\n            <Text strong style={{ fontSize: 16 }}>\n              {response.userName}\n            </Text>\n            {response.isVerified && (\n              <Tag color=\"green\" icon={<CheckCircleOutlined />}>\n                Verified\n              </Tag>\n            )}\n          </Space>\n          <Text type=\"secondary\" style={{ fontSize: 12 }}>\n            {dayjs(response.createdAt).fromNow()}\n          </Text>\n        </div>\n\n        {/* Comment */}\n        <Paragraph style={{ marginBottom: 16, fontSize: 14 }}>\n          {response.comment}\n        </Paragraph>\n\n        {/* Quoted Text (if any) */}\n        {response.quotedText && (\n          <div style={{\n            padding: 12,\n            background: 'rgba(255,255,255,0.05)',\n            borderLeft: '3px solid #1890ff',\n            marginBottom: 16,\n            fontStyle: 'italic'\n          }}>\n            <Text type=\"secondary\" style={{ fontSize: 13 }}>\n              \"{response.quotedText}\"\n            </Text>\n          </div>\n        )}\n\n        {/* Representative Info */}\n        <div style={{\n          paddingTop: 16,\n          borderTop: '1px solid rgba(255,255,255,0.1)',\n          marginBottom: 12\n        }}>\n          <Text type=\"secondary\" style={{ fontSize: 12 }}>\n            Sent to:{' '}\n          </Text>\n          <Text strong style={{ fontSize: 13 }}>\n            {response.representativeName}\n          </Text>\n          {response.representativeDistrict && (\n            <Text type=\"secondary\" style={{ fontSize: 12 }}>\n              {' '}\u2022 {response.representativeDistrict}\n            </Text>\n          )}\n          <div style={{ marginTop: 4 }}>\n            <Tag color={\n              response.governmentLevel === 'federal' ? 'blue' :\n              response.governmentLevel === 'provincial' ? 'purple' :\n              'green'\n            }>\n              {response.governmentLevel.charAt(0).toUpperCase() + response.governmentLevel.slice(1)}\n            </Tag>\n          </div>\n        </div>\n\n        {/* Upvote Button */}\n        <Button\n          type={response.hasUpvoted ? 'primary' : 'default'}\n          icon={response.hasUpvoted ? <HeartFilled /> : <HeartOutlined />}\n          onClick={() => handleUpvote(response.id)}\n          style={{\n            borderColor: '#eb2f96',\n            color: response.hasUpvoted ? 'white' : '#eb2f96'\n          }}\n        >\n          {response.upvoteCount} {response.upvoteCount === 1 ? 'Upvote' : 'Upvotes'}\n        </Button>\n      </Card>\n    </Col>\n  ))}\n</Row>\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#submit-response-modal","title":"Submit Response Modal","text":"
<Modal\n  title=\"Submit Your Response\"\n  open={submitModalVisible}\n  onCancel={() => {\n    setSubmitModalVisible(false);\n    form.resetFields();\n  }}\n  footer={null}\n  width={600}\n>\n  <Form\n    form={form}\n    layout=\"vertical\"\n    onFinish={handleSubmit}\n  >\n    <Form.Item\n      name=\"userName\"\n      label=\"Your Name\"\n      rules={[\n        { required: true, message: 'Please enter your name' },\n        { min: 2, message: 'Name must be at least 2 characters' }\n      ]}\n    >\n      <Input size=\"large\" placeholder=\"Jane Doe\" />\n    </Form.Item>\n\n    <Form.Item\n      name=\"userEmail\"\n      label=\"Your Email\"\n      rules={[\n        { required: true, message: 'Please enter your email' },\n        { type: 'email', message: 'Please enter a valid email' }\n      ]}\n    >\n      <Input size=\"large\" type=\"email\" placeholder=\"jane@example.com\" />\n    </Form.Item>\n\n    <Form.Item\n      name=\"postalCode\"\n      label=\"Your Postal Code (Optional)\"\n    >\n      <Input\n        size=\"large\"\n        placeholder=\"K1A 0B1\"\n        maxLength={7}\n        style={{ textTransform: 'uppercase' }}\n      />\n    </Form.Item>\n\n    <Form.Item\n      name=\"representativeName\"\n      label=\"Representative You Contacted\"\n      rules={[{ required: true, message: 'Please enter representative name' }]}\n    >\n      <Input size=\"large\" placeholder=\"e.g., John Smith\" />\n    </Form.Item>\n\n    <Form.Item\n      name=\"representativeDistrict\"\n      label=\"District/Riding (Optional)\"\n    >\n      <Input size=\"large\" placeholder=\"e.g., Ottawa Centre\" />\n    </Form.Item>\n\n    <Form.Item\n      name=\"governmentLevel\"\n      label=\"Government Level\"\n      rules={[{ required: true, message: 'Please select government level' }]}\n      initialValue=\"federal\"\n    >\n      <Select size=\"large\">\n        <Option value=\"federal\">Federal</Option>\n        <Option value=\"provincial\">Provincial/Territorial</Option>\n        <Option value=\"municipal\">Municipal</Option>\n      </Select>\n    </Form.Item>\n\n    <Form.Item\n      name=\"comment\"\n      label=\"Your Comment\"\n      rules={[\n        { required: true, message: 'Please enter your comment' },\n        { min: 10, message: 'Comment must be at least 10 characters' },\n        { max: 5000, message: 'Comment must be less than 5000 characters' }\n      ]}\n    >\n      <TextArea\n        rows={5}\n        placeholder=\"Share your thoughts, the response you received, or why this issue matters to you...\"\n        showCount\n        maxLength={5000}\n      />\n    </Form.Item>\n\n    <Form.Item\n      name=\"sendCopy\"\n      valuePropName=\"checked\"\n      initialValue={true}\n    >\n      <Checkbox>\n        Email me a copy and verification link\n      </Checkbox>\n    </Form.Item>\n\n    <Form.Item>\n      <Space style={{ width: '100%', justifyContent: 'flex-end' }}>\n        <Button onClick={() => {\n          setSubmitModalVisible(false);\n          form.resetFields();\n        }}>\n          Cancel\n        </Button>\n        <Button type=\"primary\" htmlType=\"submit\" size=\"large\">\n          Submit Response\n        </Button>\n      </Space>\n    </Form.Item>\n  </Form>\n</Modal>\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#pagination","title":"Pagination","text":"
{total > pageSize && (\n  <div style={{ textAlign: 'center', marginTop: 32 }}>\n    <Pagination\n      current={page}\n      total={total}\n      pageSize={pageSize}\n      onChange={(newPage) => {\n        setPage(newPage);\n        window.scrollTo({ top: 0, behavior: 'smooth' });\n      }}\n      showSizeChanger={false}\n      showTotal={(total, range) => `${range[0]}-${range[1]} of ${total} responses`}\n    />\n  </div>\n)}\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#1-parallel-data-fetching","title":"1. Parallel Data Fetching","text":"

Campaign, stats, and responses fetched simultaneously:

const [campaignRes, statsRes, responsesRes] = await Promise.all([\n  axios.get(`/api/public/campaigns/${campaignId}`),\n  axios.get(`/api/public/responses/campaigns/${campaignId}/stats`),\n  axios.get(`/api/public/responses/campaigns/${campaignId}`, { params })\n]);\n

Benefit: Reduces initial load time by ~60% vs sequential requests.

"},{"location":"v2/frontend/pages/public/response-wall-page/#2-optimistic-upvote-updates","title":"2. Optimistic Upvote Updates","text":"

UI updates immediately before API confirmation:

// Update UI first\nsetResponses(prev => prev.map(r => {\n  if (r.id === responseId) {\n    return { ...r, hasUpvoted: !r.hasUpvoted, upvoteCount: r.upvoteCount + 1 };\n  }\n  return r;\n}));\n\n// Then API call\nawait axios.post(`/api/public/responses/${responseId}/upvote`);\n

Benefit: Perceived performance improvement, instant feedback.

"},{"location":"v2/frontend/pages/public/response-wall-page/#3-server-side-filtering","title":"3. Server-Side Filtering","text":"

All filtering/sorting done via API query params (not client-side):

params: {\n  sortBy,\n  governmentLevel: governmentLevel === 'all' ? undefined : governmentLevel\n}\n

Benefit: Scalable to thousands of responses, no client memory issues.

"},{"location":"v2/frontend/pages/public/response-wall-page/#4-pagination","title":"4. Pagination","text":"

Limited to 20 responses per page:

const pageSize = 20;\n

Benefit: Reduces DOM nodes, faster render, better mobile performance.

"},{"location":"v2/frontend/pages/public/response-wall-page/#5-scroll-to-top-on-page-change","title":"5. Scroll to Top on Page Change","text":"

Smooth scroll when pagination changes:

onChange={(newPage) => {\n  setPage(newPage);\n  window.scrollTo({ top: 0, behavior: 'smooth' });\n}}\n

Benefit: Better UX, user doesn't miss new content.

"},{"location":"v2/frontend/pages/public/response-wall-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#breakpoint-behavior","title":"Breakpoint Behavior","text":"Breakpoint Stats Columns Response Cards Filter Layout Modal Width xs (0-575px) 1 column 1 column Stacked 90% viewport sm (576-767px) 3 columns 1 column Stacked 90% viewport md (768-991px) 3 columns 1 column Side-by-side 600px lg (992px+) 3 columns 1 column Side-by-side 600px"},{"location":"v2/frontend/pages/public/response-wall-page/#mobile-adaptations","title":"Mobile Adaptations","text":"

Statistics Cards: - Stack vertically on xs (easier to scan) - Show 3 columns on sm+ (compact display) - Font size remains large (32px) for impact

Response Cards: - Always full-width (xs=24) - Better readability on narrow screens - Upvote button full-width on mobile (future enhancement)

Sort/Filter Controls: - Stack vertically on xs (full-width selects) - Side-by-side on sm+ (50% width each) - Labels above selects for clarity

Submit Modal: - Width adapts to viewport (90% on mobile, 600px desktop) - Form fields always full-width - TextArea shrinks to 3 rows on mobile (vs 5 desktop)

"},{"location":"v2/frontend/pages/public/response-wall-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#keyboard-navigation","title":"Keyboard Navigation","text":"

Response Cards: - Upvote button focusable via Tab - Enter/Space toggles upvote

Sort/Filter Controls: - Dropdowns keyboard navigable (Arrow keys + Enter) - Focus visible on all select elements

Pagination: - Page numbers focusable - Arrow keys navigate pages (native Ant Design)

"},{"location":"v2/frontend/pages/public/response-wall-page/#aria-labels","title":"ARIA Labels","text":"

Upvote Button:

<Button\n  aria-label={`Upvote response by ${response.userName}. Current upvotes: ${response.upvoteCount}`}\n  onClick={() => handleUpvote(response.id)}\n>\n  {response.upvoteCount} Upvotes\n</Button>\n

Statistics Cards:

<Statistic\n  title=\"Total Responses\"\n  value={stats.totalResponses}\n  aria-label={`Total responses: ${stats.totalResponses}`}\n/>\n

Modal:

<Modal\n  title=\"Submit Your Response\"\n  aria-labelledby=\"submit-response-title\"\n  aria-describedby=\"submit-response-description\"\n>\n

"},{"location":"v2/frontend/pages/public/response-wall-page/#screen-reader-support","title":"Screen Reader Support","text":"

Verification Badge:

<Tag color=\"green\" icon={<CheckCircleOutlined />}>\n  <span aria-label=\"Email verified\">Verified</span>\n</Tag>\n

Timestamp:

<Text\n  type=\"secondary\"\n  aria-label={`Posted ${dayjs(response.createdAt).format('MMMM D, YYYY at h:mm A')}`}\n>\n  {dayjs(response.createdAt).fromNow()}\n</Text>\n

Form Validation: - Error messages announced automatically - Required field indicators (required attribute) - Help text linked via aria-describedby

"},{"location":"v2/frontend/pages/public/response-wall-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#issue-upvotes-not-persisting","title":"Issue: Upvotes Not Persisting","text":"

Symptoms: - User clicks upvote, count increments - Page refresh resets upvote - Heart icon reverts to outline

Causes: 1. API call failing silently 2. Session/cookie not persisting user ID 3. Optimistic update not reverting on error 4. Backend not tracking upvote source

Solutions:

const handleUpvote = async (responseId: string) => {\n  // Save previous state for rollback\n  const previousResponses = [...responses];\n\n  // Optimistic update\n  setResponses(prev => prev.map(r => {\n    if (r.id === responseId) {\n      return {\n        ...r,\n        hasUpvoted: !r.hasUpvoted,\n        upvoteCount: r.upvoteCount + (r.hasUpvoted ? -1 : 1)\n      };\n    }\n    return r;\n  }));\n\n  try {\n    const response = await axios.post(\n      `/api/public/responses/${responseId}/upvote`,\n      {},\n      { timeout: 5000 }\n    );\n\n    console.log('Upvote response:', response.data);\n\n    // Update with server count (authoritative)\n    setResponses(prev => prev.map(r =>\n      r.id === responseId\n        ? {\n            ...r,\n            upvoteCount: response.data.upvoteCount,\n            hasUpvoted: response.data.action === 'added'\n          }\n        : r\n    ));\n\n  } catch (error: any) {\n    console.error('Upvote failed:', error);\n\n    // Revert to previous state\n    setResponses(previousResponses);\n\n    if (error.code === 'ECONNABORTED') {\n      message.error('Request timed out. Please try again.');\n    } else {\n      message.error('Failed to upvote. Please try again.');\n    }\n  }\n};\n

Check backend upvote tracking:

-- Verify upvote records created\nSELECT * FROM \"ResponseUpvote\"\nWHERE \"responseId\" = 'cm2abc123'\nORDER BY \"createdAt\" DESC;\n

"},{"location":"v2/frontend/pages/public/response-wall-page/#issue-statistics-not-updating-after-submission","title":"Issue: Statistics Not Updating After Submission","text":"

Symptoms: - User submits response - Response appears in list - Statistics cards show old counts

Causes: 1. Stats fetched once on mount, never refreshed 2. New response not included in stats query 3. Cache invalidation not working

Solutions:

// Refetch stats after successful submission\nconst handleSubmit = async (values: any) => {\n  try {\n    await axios.post('/api/public/responses', { ... });\n\n    Modal.success({\n      title: 'Response Submitted!',\n      content: 'Please check your email to verify your response.',\n    });\n\n    setSubmitModalVisible(false);\n    form.resetFields();\n\n    // Refresh all data\n    const [statsRes, responsesRes] = await Promise.all([\n      axios.get(`/api/public/responses/campaigns/${campaignId}/stats`),\n      axios.get(`/api/public/responses/campaigns/${campaignId}`, {\n        params: { page: 1, limit: pageSize, sortBy, governmentLevel }\n      })\n    ]);\n\n    setStats(statsRes.data);\n    setResponses(responsesRes.data.responses);\n    setTotal(responsesRes.data.total);\n    setPage(1); // Reset to first page\n\n  } catch (error: any) {\n    message.error(error.response?.data?.message || 'Failed to submit response');\n  }\n};\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#issue-verified-only-filter-shows-no-results","title":"Issue: \"Verified Only\" Filter Shows No Results","text":"

Symptoms: - User selects \"Verified Only\" sort - Grid shows empty state - Total count remains high

Causes: 1. No verified responses exist yet 2. API not filtering correctly 3. Frontend not passing correct param

Solutions:

// Add empty state for no verified responses\n{!loading && responses.length === 0 && sortBy === 'verified' && (\n  <Card style={{ textAlign: 'center', padding: 40 }}>\n    <CheckCircleOutlined style={{ fontSize: 64, color: '#999', marginBottom: 16 }} />\n    <Title level={3} type=\"secondary\">\n      No Verified Responses Yet\n    </Title>\n    <Paragraph type=\"secondary\">\n      Responses appear here after users verify their email address.\n      <br />\n      Try selecting \"Recent\" or \"Most Upvoted\" to see all responses.\n    </Paragraph>\n    <Button\n      type=\"primary\"\n      onClick={() => setSortBy('recent')}\n    >\n      View All Responses\n    </Button>\n  </Card>\n)}\n\n// Verify API param correctly passed\nuseEffect(() => {\n  console.log('Fetching with params:', {\n    page,\n    limit: pageSize,\n    sortBy,\n    governmentLevel\n  });\n}, [page, sortBy, governmentLevel]);\n

Check backend:

-- Count verified vs unverified\nSELECT \"isVerified\", COUNT(*)\nFROM \"Response\"\nWHERE \"campaignId\" = 'cm1abc123'\nGROUP BY \"isVerified\";\n

"},{"location":"v2/frontend/pages/public/response-wall-page/#issue-pagination-showing-wrong-total","title":"Issue: Pagination Showing Wrong Total","text":"

Symptoms: - Pagination shows \"1-20 of 342\" - Only 50 total responses exist - Total count doesn't match stats card

Causes: 1. Stats query counting all campaigns 2. Responses query filtering by campaign correctly 3. Stats API endpoint broken

Solutions:

// Use responses total, not stats total, for pagination\nconst [responsesTotal, setResponsesTotal] = useState(0);\n\n// In fetch responses:\nsetResponsesTotal(responsesRes.data.total);\n\n// In pagination:\n<Pagination\n  current={page}\n  total={responsesTotal} // Not stats.totalResponses\n  pageSize={pageSize}\n  onChange={setPage}\n/>\n\n// Add validation\nuseEffect(() => {\n  if (stats.totalResponses !== responsesTotal) {\n    console.warn('Mismatch between stats and pagination totals:', {\n      stats: stats.totalResponses,\n      pagination: responsesTotal\n    });\n  }\n}, [stats.totalResponses, responsesTotal]);\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#public-pages","title":"Public Pages","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#admin-pages","title":"Admin Pages","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#components","title":"Components","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#api-documentation","title":"API Documentation","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#architecture","title":"Architecture","text":""},{"location":"v2/frontend/pages/public/shifts-page/","title":"Public Shifts Page","text":""},{"location":"v2/frontend/pages/public/shifts-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/public/ShiftsPage.tsx (344 lines)

Route: /shifts

Role Requirements: Public access (no authentication required)

Purpose: Public volunteer shift signup interface allowing community members to register for canvassing shifts, creating temporary user accounts automatically, and receiving email confirmations.

Key Features:

Layout: Uses PublicLayout with dark theme

"},{"location":"v2/frontend/pages/public/shifts-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/shifts-page/#1-hero-banner","title":"1. Hero Banner","text":"

Prominent call-to-action header:

"},{"location":"v2/frontend/pages/public/shifts-page/#2-shift-cards-grid","title":"2. Shift Cards Grid","text":"

Responsive grid displaying available shifts:

Card Contents: - Shift title (Typography.Title level 4) - Date and time range (formatted with dayjs) - Location address - Cut/district name (if assigned) - Description (truncated, 3-line ellipsis) - Volunteer capacity progress bar - \"Sign Up\" button (primary, full-width)

Styling: - Dark card background (colorBgContainer) - Hover elevation effect - 24px gutter between cards - Rounded corners (8px)

Capacity Indicators: - Green progress bar (0-70% full) - Yellow progress bar (71-90% full) - Red progress bar (91-100% full) - Text: \"X of Y volunteers signed up\"

Full Shifts: - Card opacity reduced to 0.6 - Button disabled with \"Full\" text - Badge showing \"Full\" in red

"},{"location":"v2/frontend/pages/public/shifts-page/#3-signup-modal","title":"3. Signup Modal","text":"

User registration form:

Form Fields: - Your Name (required, min 2 chars) - Email (required, email validation) - Phone (required, phone number format)

Shift Details Display: - Shift title (read-only) - Date/time (read-only) - Location (read-only)

Submission: - Creates temporary user if not logged in - Creates shift signup record - Sends confirmation email - Opens success modal

Validation: - Required field indicators - Email format check - Phone format (10 digits, (XXX) XXX-XXXX) - Duplicate signup prevention

"},{"location":"v2/frontend/pages/public/shifts-page/#4-success-modal","title":"4. Success Modal","text":"

Post-signup confirmation:

Content: - Green checkmark icon - \"Successfully Signed Up!\" heading - Shift details (title, date, time, location) - Email confirmation message - \"OK\" button to close

Behavior: - Auto-opens after successful signup - Reloads shift list on close (to show updated capacity)

"},{"location":"v2/frontend/pages/public/shifts-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/public/shifts-page/#browsing-shifts","title":"Browsing Shifts","text":"
  1. User navigates to /shifts
  2. Hero banner loads with CTA
  3. API fetches active shifts
  4. Shift cards render in grid
  5. User sees capacity bars (green/yellow/red)
  6. User scrolls through available shifts
"},{"location":"v2/frontend/pages/public/shifts-page/#signing-up-for-shift","title":"Signing Up for Shift","text":"
  1. User finds desired shift card
  2. User clicks \"Sign Up\" button
  3. Modal opens with signup form
  4. User enters name: \"Jane Doe\"
  5. User enters email: \"jane@example.com\"
  6. User enters phone: \"(555) 123-4567\"
  7. User clicks \"Sign Up\" submit button
  8. API creates temp user (role: TEMP)
  9. API creates shift signup
  10. Confirmation email sent
  11. Success modal displays
  12. User clicks \"OK\"
  13. Modal closes
  14. Shift list refreshes
  15. Signed-up shift shows updated capacity
"},{"location":"v2/frontend/pages/public/shifts-page/#full-shift-handling","title":"Full Shift Handling","text":"
  1. User sees shift with red progress bar (full)
  2. Card has reduced opacity
  3. Button shows \"Full\" and is disabled
  4. User cannot click signup
  5. \"Full\" badge visible on card
"},{"location":"v2/frontend/pages/public/shifts-page/#component-structure","title":"Component Structure","text":"
import React, { useState, useEffect } from 'react';\nimport { Card, Row, Col, Typography, Button, Form, Input, Modal, Progress, Tag, Grid, message } from 'antd';\nimport { CalendarOutlined, CheckCircleOutlined, ClockCircleOutlined, EnvironmentOutlined } from '@ant-design/icons';\nimport dayjs from 'dayjs';\nimport PublicLayout from '../../components/PublicLayout';\nimport axios from 'axios';\n\nconst { Title, Paragraph, Text } = Typography;\nconst { useBreakpoint } = Grid;\n\ninterface Shift {\n  id: string;\n  title: string;\n  description: string | null;\n  date: string;\n  startTime: string;\n  endTime: string;\n  location: string;\n  maxVolunteers: number;\n  currentSignups: number;\n  cutName: string | null;\n}\n\nconst ShiftsPage: React.FC = () => {\n  const [shifts, setShifts] = useState<Shift[]>([]);\n  const [loading, setLoading] = useState(true);\n  const [signupModalVisible, setSignupModalVisible] = useState(false);\n  const [selectedShift, setSelectedShift] = useState<Shift | null>(null);\n  const [form] = Form.useForm();\n  const screens = useBreakpoint();\n  const isMobile = !screens.md;\n\n  return (\n    <PublicLayout>\n      {/* Hero Banner */}\n      {/* Shift Cards Grid */}\n      {/* Signup Modal */}\n      {/* Success Modal */}\n    </PublicLayout>\n  );\n};\n
"},{"location":"v2/frontend/pages/public/shifts-page/#state-management","title":"State Management","text":"
const [shifts, setShifts] = useState<Shift[]>([]);\nconst [loading, setLoading] = useState(true);\nconst [signupModalVisible, setSignupModalVisible] = useState(false);\nconst [selectedShift, setSelectedShift] = useState<Shift | null>(null);\nconst [successModalVisible, setSuccessModalVisible] = useState(false);\nconst [form] = Form.useForm();\n
"},{"location":"v2/frontend/pages/public/shifts-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/public/shifts-page/#endpoints","title":"Endpoints","text":""},{"location":"v2/frontend/pages/public/shifts-page/#1-list-public-shifts","title":"1. List Public Shifts","text":"
GET /api/public/map/shifts\n

Response:

[\n  {\n    \"id\": \"cm1abc123\",\n    \"title\": \"Weekend Canvass - Downtown\",\n    \"description\": \"Door-to-door canvassing in the downtown district\",\n    \"date\": \"2025-02-15\",\n    \"startTime\": \"10:00\",\n    \"endTime\": \"14:00\",\n    \"location\": \"123 Main St, Campaign Office\",\n    \"maxVolunteers\": 10,\n    \"currentSignups\": 7,\n    \"cutName\": \"Downtown District\"\n  }\n]\n

"},{"location":"v2/frontend/pages/public/shifts-page/#2-sign-up-for-shift","title":"2. Sign Up for Shift","text":"
POST /api/public/map/shifts/:id/signup\nContent-Type: application/json\n\n{\n  \"name\": \"Jane Doe\",\n  \"email\": \"jane@example.com\",\n  \"phone\": \"(555) 123-4567\"\n}\n

Response:

{\n  \"success\": true,\n  \"signupId\": \"cm2def456\",\n  \"message\": \"Successfully signed up! Confirmation email sent.\"\n}\n

"},{"location":"v2/frontend/pages/public/shifts-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/public/shifts-page/#shift-card-with-capacity-bar","title":"Shift Card with Capacity Bar","text":"
{shifts.map(shift => {\n  const isFull = shift.currentSignups >= shift.maxVolunteers;\n  const percentage = (shift.currentSignups / shift.maxVolunteers) * 100;\n\n  const progressColor = \n    percentage < 70 ? '#52c41a' :\n    percentage < 90 ? '#faad14' :\n    '#f5222d';\n\n  return (\n    <Col xs={24} sm={12} lg={8} key={shift.id}>\n      <Card\n        hoverable={!isFull}\n        style={{ opacity: isFull ? 0.6 : 1 }}\n      >\n        {isFull && (\n          <Tag color=\"red\" style={{ position: 'absolute', top: 16, right: 16 }}>\n            Full\n          </Tag>\n        )}\n\n        <Title level={4} style={{ marginBottom: 12 }}>\n          {shift.title}\n        </Title>\n\n        <Space direction=\"vertical\" size={8} style={{ width: '100%', marginBottom: 16 }}>\n          <Text>\n            <CalendarOutlined /> {dayjs(shift.date).format('MMMM D, YYYY')}\n          </Text>\n          <Text>\n            <ClockCircleOutlined /> {shift.startTime} - {shift.endTime}\n          </Text>\n          <Text>\n            <EnvironmentOutlined /> {shift.location}\n          </Text>\n          {shift.cutName && (\n            <Tag color=\"blue\">{shift.cutName}</Tag>\n          )}\n        </Space>\n\n        {shift.description && (\n          <Paragraph ellipsis={{ rows: 3 }} type=\"secondary\" style={{ marginBottom: 16, minHeight: 66 }}>\n            {shift.description}\n          </Paragraph>\n        )}\n\n        <div style={{ marginBottom: 16 }}>\n          <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>\n            <Text type=\"secondary\" style={{ fontSize: 12 }}>\n              {shift.currentSignups} of {shift.maxVolunteers} volunteers\n            </Text>\n            <Text type=\"secondary\" style={{ fontSize: 12 }}>\n              {Math.round(percentage)}%\n            </Text>\n          </div>\n          <Progress\n            percent={percentage}\n            strokeColor={progressColor}\n            showInfo={false}\n          />\n        </div>\n\n        <Button\n          type=\"primary\"\n          block\n          disabled={isFull}\n          onClick={() => {\n            setSelectedShift(shift);\n            setSignupModalVisible(true);\n          }}\n        >\n          {isFull ? 'Full' : 'Sign Up'}\n        </Button>\n      </Card>\n    </Col>\n  );\n})}\n
"},{"location":"v2/frontend/pages/public/shifts-page/#signup-modal","title":"Signup Modal","text":"
<Modal\n  title=\"Sign Up for Shift\"\n  open={signupModalVisible}\n  onCancel={() => {\n    setSignupModalVisible(false);\n    form.resetFields();\n  }}\n  footer={null}\n  width={500}\n>\n  {selectedShift && (\n    <>\n      <div style={{\n        background: '#e6f7ff',\n        padding: 16,\n        borderRadius: 8,\n        marginBottom: 24\n      }}>\n        <Title level={5} style={{ marginBottom: 8 }}>\n          {selectedShift.title}\n        </Title>\n        <Text>\n          <CalendarOutlined /> {dayjs(selectedShift.date).format('MMMM D, YYYY')}\n        </Text>\n        <br />\n        <Text>\n          <ClockCircleOutlined /> {selectedShift.startTime} - {selectedShift.endTime}\n        </Text>\n        <br />\n        <Text>\n          <EnvironmentOutlined /> {selectedShift.location}\n        </Text>\n      </div>\n\n      <Form form={form} layout=\"vertical\" onFinish={handleSignup}>\n        <Form.Item\n          name=\"name\"\n          label=\"Your Name\"\n          rules={[\n            { required: true, message: 'Please enter your name' },\n            { min: 2, message: 'Name must be at least 2 characters' }\n          ]}\n        >\n          <Input size=\"large\" placeholder=\"Jane Doe\" />\n        </Form.Item>\n\n        <Form.Item\n          name=\"email\"\n          label=\"Email\"\n          rules={[\n            { required: true, message: 'Please enter your email' },\n            { type: 'email', message: 'Please enter a valid email' }\n          ]}\n        >\n          <Input size=\"large\" type=\"email\" placeholder=\"jane@example.com\" />\n        </Form.Item>\n\n        <Form.Item\n          name=\"phone\"\n          label=\"Phone Number\"\n          rules={[\n            { required: true, message: 'Please enter your phone number' },\n            { pattern: /^\\(?([0-9]{3})\\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/, message: 'Invalid phone format' }\n          ]}\n        >\n          <Input size=\"large\" placeholder=\"(555) 123-4567\" />\n        </Form.Item>\n\n        <Form.Item>\n          <Button type=\"primary\" htmlType=\"submit\" size=\"large\" block loading={loading}>\n            Sign Up\n          </Button>\n        </Form.Item>\n      </Form>\n    </>\n  )}\n</Modal>\n
"},{"location":"v2/frontend/pages/public/shifts-page/#performance-considerations","title":"Performance Considerations","text":"
  1. Single API Call: All shifts fetched once on mount
  2. Optimistic UI: Capacity updates immediately after signup
  3. Form Reset: Clears fields after successful submission
  4. Debounced Validation: Email/phone validation on blur, not keystroke
"},{"location":"v2/frontend/pages/public/shifts-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/public/shifts-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/public/shifts-page/#issue-phone-validation-failing","title":"Issue: Phone Validation Failing","text":"

Solution:

// Normalize phone input\nconst normalizePhone = (value: string) => {\n  const cleaned = value.replace(/\\D/g, '');\n  if (cleaned.length === 10) {\n    return `(${cleaned.slice(0,3)}) ${cleaned.slice(3,6)}-${cleaned.slice(6)}`;\n  }\n  return value;\n};\n\n<Form.Item normalize={normalizePhone}>\n  <Input />\n</Form.Item>\n

"},{"location":"v2/frontend/pages/public/shifts-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/volunteer/","title":"Volunteer Pages","text":"

Volunteer pages provide the volunteer portal interface for canvassing activities. These pages require authentication and are optimized for field use with GPS tracking and mobile responsiveness.

"},{"location":"v2/frontend/pages/volunteer/#route-context","title":"Route Context","text":""},{"location":"v2/frontend/pages/volunteer/#dashboard","title":"Dashboard","text":""},{"location":"v2/frontend/pages/volunteer/#volunteer-dashboard","title":"Volunteer Dashboard","text":"

Route: /volunteer/dashboard

Volunteer overview with:

Features: - Visit count and outcomes - Next shift information - Activity summary - Mobile responsive

"},{"location":"v2/frontend/pages/volunteer/#shift-management","title":"Shift Management","text":""},{"location":"v2/frontend/pages/volunteer/#volunteer-shifts-page","title":"Volunteer Shifts Page","text":"

Route: /volunteer/assignments

Assigned shifts for logged-in volunteer:

Features: - Filter by date - Cut assignment display - Quick start canvassing - Email notifications - Mobile responsive

Note: Only shows shifts with cutId assigned (required for canvassing).

"},{"location":"v2/frontend/pages/volunteer/#canvassing","title":"Canvassing","text":""},{"location":"v2/frontend/pages/volunteer/#volunteer-map-page","title":"Volunteer Map Page","text":"

Route: /volunteer/canvass/:cutId

Full-screen GPS canvass map:

Features: - GPS position tracking - Auto-center on position - Color-coded markers: - Red - Not visited - Blue - Next in route - Green - Visited (success outcomes) - Orange - Visited (neutral outcomes) - Red - Visited (negative outcomes) - Click marker to record visit - Walking route algorithm - Session management - Mobile optimized

Full-Screen: - No layout wrapper - Custom header with timer - Bottom sheet controls - Optimized for field use

GPS Tracking: - Watch position API - Blue GPS marker - Accuracy circle - Auto-update every 5 seconds

Visit Recording: - Outcome selection (NOT_HOME, MOVED, REFUSED, etc.) - Notes field - GPS coordinates captured - Rate limited (30/min)

Session Management: - Start/end session - Elapsed timer - Abandoned session cleanup (12h) - Progress tracking

"},{"location":"v2/frontend/pages/volunteer/#activity-tracking","title":"Activity Tracking","text":""},{"location":"v2/frontend/pages/volunteer/#my-activity-page","title":"My Activity Page","text":"

Route: /volunteer/activity

Visit history and statistics:

Features: - Filter by date range - Group by session - Outcome pie chart - Total visit count - Mobile responsive

"},{"location":"v2/frontend/pages/volunteer/#my-routes-page","title":"My Routes Page","text":"

Route: /volunteer/routes

Walking route history:

Features: - Route visualization - Session details - Statistics summary - Mobile responsive

"},{"location":"v2/frontend/pages/volunteer/#volunteer-page-count","title":"Volunteer Page Count","text":"

Total: 4 volunteer pages

"},{"location":"v2/frontend/pages/volunteer/#common-features","title":"Common Features","text":"

Volunteer pages share:

"},{"location":"v2/frontend/pages/volunteer/#authentication","title":"Authentication","text":"

All volunteer pages require authentication:

// Volunteer routes use authenticate middleware\nrouter.get('/api/canvass/session', authenticate, ...)\n

Role-based redirect after login: - ADMIN roles \u2192 /app/dashboard - USER/TEMP roles \u2192 /volunteer/dashboard

"},{"location":"v2/frontend/pages/volunteer/#state-management","title":"State Management","text":"

Volunteer pages use Zustand canvass store:

// stores/canvass.store.ts\ninterface CanvassStore {\n  session: CanvassSession | null;\n  locations: CanvassLocation[];\n  route: WalkingRoute | null;\n  gpsPosition: { lat: number; lng: number } | null;\n  currentLocationIndex: number;\n  // ... actions\n}\n
"},{"location":"v2/frontend/pages/volunteer/#gps-tracking","title":"GPS Tracking","text":"

GPS tracking uses browser Geolocation API:

navigator.geolocation.watchPosition(\n  (position) => {\n    setGpsPosition({\n      lat: position.coords.latitude,\n      lng: position.coords.longitude,\n    });\n  },\n  (error) => console.error('GPS error:', error),\n  {\n    enableHighAccuracy: true,\n    timeout: 5000,\n    maximumAge: 0,\n  }\n);\n
"},{"location":"v2/frontend/pages/volunteer/#walking-route-algorithm","title":"Walking Route Algorithm","text":"

Routes are calculated server-side using nearest-neighbor algorithm:

  1. Start at closest location to shift start
  2. For each subsequent location:
  3. Find nearest unvisited location
  4. Add to route
  5. Return ordered location list

Frontend displays route as blue polyline connecting locations.

"},{"location":"v2/frontend/pages/volunteer/#visit-outcomes","title":"Visit Outcomes","text":"

Available outcomes in recording form:

"},{"location":"v2/frontend/pages/volunteer/#session-lifecycle","title":"Session Lifecycle","text":"
  1. Start Session - Create session record, generate route
  2. GPS Tracking - Track position, update markers
  3. Visit Locations - Record outcomes, update route
  4. End Session - Close session, save statistics
  5. Abandoned Cleanup - Auto-close after 12 hours
"},{"location":"v2/frontend/pages/volunteer/#mobile-optimization","title":"Mobile Optimization","text":"

Volunteer pages are optimized for mobile:

"},{"location":"v2/frontend/pages/volunteer/#performance","title":"Performance","text":"

GPS canvass map optimizations:

"},{"location":"v2/frontend/pages/volunteer/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/volunteer/my-activity-page/","title":"My Activity Page","text":""},{"location":"v2/frontend/pages/volunteer/my-activity-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/volunteer/MyActivityPage.tsx (137 lines)

Route: /volunteer/activity

Role Requirements: Authenticated users

Purpose: Volunteer activity dashboard showing canvassing statistics and visit history.

Key Features:

"},{"location":"v2/frontend/pages/volunteer/my-activity-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/volunteer/my-activity-page/#1-statistics-cards","title":"1. Statistics Cards","text":"
<Row gutter={16}>\n  <Col xs={24} sm={8}>\n    <Card>\n      <Statistic\n        title=\"Today's Visits\"\n        value={stats.todayVisits}\n        prefix={<CheckCircleOutlined />}\n        valueStyle={{ color: '#52c41a' }}\n      />\n    </Card>\n  </Col>\n\n  <Col xs={24} sm={8}>\n    <Card>\n      <Statistic\n        title=\"Total Doors\"\n        value={stats.totalDoors}\n        prefix={<HomeOutlined />}\n        valueStyle={{ color: '#1890ff' }}\n      />\n    </Card>\n  </Col>\n\n  <Col xs={24} sm={8}>\n    <Card>\n      <Statistic\n        title=\"Total Sessions\"\n        value={stats.totalSessions}\n        prefix={<ClockCircleOutlined />}\n        valueStyle={{ color: '#722ed1' }}\n      />\n    </Card>\n  </Col>\n</Row>\n
"},{"location":"v2/frontend/pages/volunteer/my-activity-page/#2-outcome-breakdown","title":"2. Outcome Breakdown","text":"
<Card title=\"Outcome Breakdown\">\n  <Space wrap>\n    <Tag color=\"green\">Strong Support: {outcomes.strongSupport}</Tag>\n    <Tag color=\"blue\">Leaning Support: {outcomes.leaningSupport}</Tag>\n    <Tag color=\"yellow\">Undecided: {outcomes.undecided}</Tag>\n    <Tag color=\"orange\">Leaning Opposed: {outcomes.leaningOpposed}</Tag>\n    <Tag color=\"red\">Opposed: {outcomes.opposed}</Tag>\n    <Tag color=\"default\">No Answer: {outcomes.noAnswer}</Tag>\n    <Tag color=\"gray\">Not Home: {outcomes.notHome}</Tag>\n  </Space>\n</Card>\n
"},{"location":"v2/frontend/pages/volunteer/my-activity-page/#3-visit-history-table","title":"3. Visit History Table","text":"
<Table\n  dataSource={visits}\n  columns={[\n    {\n      title: 'Address',\n      dataIndex: 'address',\n      key: 'address'\n    },\n    {\n      title: 'Outcome',\n      dataIndex: 'outcome',\n      key: 'outcome',\n      render: (outcome) => (\n        <Tag color={getOutcomeColor(outcome)}>\n          {outcome.replace('_', ' ')}\n        </Tag>\n      )\n    },\n    {\n      title: 'Notes',\n      dataIndex: 'notes',\n      key: 'notes',\n      ellipsis: true\n    },\n    {\n      title: 'Time',\n      dataIndex: 'createdAt',\n      key: 'createdAt',\n      render: (date) => dayjs(date).format('MMM D, h:mm A')\n    }\n  ]}\n  pagination={{\n    current: page,\n    total: total,\n    pageSize: 20,\n    onChange: setPage\n  }}\n/>\n
"},{"location":"v2/frontend/pages/volunteer/my-activity-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/volunteer/my-activity-page/#endpoints","title":"Endpoints","text":""},{"location":"v2/frontend/pages/volunteer/my-activity-page/#1-get-stats","title":"1. Get Stats","text":"
GET /api/map/canvass/my-stats\nAuthorization: Bearer {token}\n

Response:

{\n  \"todayVisits\": 23,\n  \"totalDoors\": 187,\n  \"totalSessions\": 12,\n  \"outcomes\": {\n    \"strongSupport\": 45,\n    \"leaningSupport\": 32,\n    \"undecided\": 28,\n    \"leaningOpposed\": 15,\n    \"opposed\": 12,\n    \"noAnswer\": 38,\n    \"notHome\": 17\n  }\n}\n

"},{"location":"v2/frontend/pages/volunteer/my-activity-page/#2-get-visit-history","title":"2. Get Visit History","text":"
GET /api/map/canvass/my-visits?page=1&limit=20\nAuthorization: Bearer {token}\n

Response:

{\n  \"visits\": [\n    {\n      \"id\": \"cm1visit123\",\n      \"address\": \"123 Main St\",\n      \"outcome\": \"strong_support\",\n      \"notes\": \"Very enthusiastic, requested yard sign\",\n      \"createdAt\": \"2025-02-12T14:30:00.000Z\"\n    }\n  ],\n  \"total\": 187,\n  \"page\": 1\n}\n

"},{"location":"v2/frontend/pages/volunteer/my-activity-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/volunteer/my-routes-page/","title":"My Routes Page","text":""},{"location":"v2/frontend/pages/volunteer/my-routes-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/volunteer/MyRoutesPage.tsx (275 lines)

Route: /volunteer/routes

Role Requirements: Authenticated users

Purpose: Visual display of volunteer's canvassing routes with map visualization and session history.

Key Features:

"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/volunteer/my-routes-page/#1-statistics-summary","title":"1. Statistics Summary","text":"
<Row gutter={16}>\n  <Col xs={24} sm={8}>\n    <Statistic\n      title=\"Total Sessions\"\n      value={stats.totalSessions}\n      prefix={<ClockCircleOutlined />}\n    />\n  </Col>\n  <Col xs={24} sm={8}>\n    <Statistic\n      title=\"Total Distance\"\n      value={`${(stats.totalDistance / 1000).toFixed(1)} km`}\n      prefix={<EnvironmentOutlined />}\n    />\n  </Col>\n  <Col xs={24} sm=8}>\n    <Statistic\n      title=\"Total Time\"\n      value={formatDuration(stats.totalDuration)}\n      prefix={<FieldTimeOutlined />}\n    />\n  </Col>\n</Row>\n
"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#2-route-map","title":"2. Route Map","text":"
<MapContainer\n  center={[45.5017, -73.5673]}\n  zoom={13}\n  style={{ height: 400 }}\n>\n  <TileLayer\n    url=\"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png\"\n    attribution='&copy; <a href=\"https://carto.com/\">CARTO</a>'\n  />\n\n  {/* Route polyline */}\n  {routeVisible && selectedRoute && (\n    <Polyline\n      positions={selectedRoute.points.map(p => [p.latitude, p.longitude])}\n      pathOptions={{ color: '#1890ff', weight: 3 }}\n    />\n  )}\n\n  {/* Event markers */}\n  {selectedRoute?.points.map(point => (\n    <CircleMarker\n      key={point.id}\n      center={[point.latitude, point.longitude]}\n      radius={6}\n      pathOptions={{\n        color: getEventColor(point.eventType),\n        fillColor: getEventColor(point.eventType),\n        fillOpacity: 1\n      }}\n    >\n      <Popup>\n        <Text strong>{point.eventType}</Text>\n        <br />\n        <Text type=\"secondary\">\n          {dayjs(point.timestamp).format('h:mm:ss A')}\n        </Text>\n      </Popup>\n    </CircleMarker>\n  ))}\n\n  {/* Fit bounds to route */}\n  {selectedRoute && <FitBounds points={selectedRoute.points} />}\n</MapContainer>\n
"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#3-event-type-legend","title":"3. Event Type Legend","text":"
<div style={{ padding: 12, background: 'rgba(0,0,0,0.7)', borderRadius: 4 }}>\n  <Space direction=\"vertical\" size={4}>\n    <Space>\n      <div style={{ width: 12, height: 12, background: '#52c41a', borderRadius: '50%' }} />\n      <Text style={{ color: 'white', fontSize: 12 }}>Session Start</Text>\n    </Space>\n    <Space>\n      <div style={{ width: 12, height: 12, background: '#f5222d', borderRadius: '50%' }} />\n      <Text style={{ color: 'white', fontSize: 12 }}>Session End</Text>\n    </Space>\n    <Space>\n      <div style={{ width: 12, height: 12, background: '#1890ff', borderRadius: '50%' }} />\n      <Text style={{ color: 'white', fontSize: 12 }}>Visit</Text>\n    </Space>\n    <Space>\n      <div style={{ width: 12, height: 12, background: '#722ed1', borderRadius: '50%' }} />\n      <Text style={{ color: 'white', fontSize: 12 }}>Location Added</Text>\n    </Space>\n  </Space>\n</div>\n
"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#4-session-history-table","title":"4. Session History Table","text":"
<Table\n  dataSource={sessions}\n  columns={[\n    {\n      title: 'Date',\n      dataIndex: 'startTime',\n      render: (date) => dayjs(date).format('MMM D, YYYY')\n    },\n    {\n      title: 'Duration',\n      key: 'duration',\n      render: (_, record) => {\n        const start = dayjs(record.startTime);\n        const end = dayjs(record.endTime);\n        return formatDuration(end.diff(start, 'seconds'));\n      }\n    },\n    {\n      title: 'Distance',\n      dataIndex: 'distance',\n      render: (distance) => `${(distance / 1000).toFixed(1)} km`\n    },\n    {\n      title: 'Points',\n      dataIndex: 'pointCount',\n      render: (count) => `${count} points`\n    },\n    {\n      title: 'Action',\n      key: 'action',\n      render: (_, record) => (\n        <Button\n          type=\"link\"\n          onClick={() => {\n            setSelectedRoute(record);\n            setRouteVisible(true);\n          }}\n        >\n          View Route\n        </Button>\n      )\n    }\n  ]}\n/>\n
"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/volunteer/my-routes-page/#endpoints","title":"Endpoints","text":""},{"location":"v2/frontend/pages/volunteer/my-routes-page/#1-get-route-stats","title":"1. Get Route Stats","text":"
GET /api/map/canvass/my-routes/stats\nAuthorization: Bearer {token}\n

Response:

{\n  \"totalSessions\": 12,\n  \"totalDistance\": 34567,\n  \"totalDuration\": 18900\n}\n

"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#2-get-session-routes","title":"2. Get Session Routes","text":"
GET /api/map/canvass/my-routes\nAuthorization: Bearer {token}\n

Response:

[\n  {\n    \"sessionId\": \"cm1session123\",\n    \"startTime\": \"2025-02-12T10:00:00.000Z\",\n    \"endTime\": \"2025-02-12T12:30:00.000Z\",\n    \"distance\": 2834,\n    \"pointCount\": 45,\n    \"points\": [\n      {\n        \"id\": \"cm1point1\",\n        \"latitude\": 45.5017,\n        \"longitude\": -73.5673,\n        \"eventType\": \"session_start\",\n        \"timestamp\": \"2025-02-12T10:00:00.000Z\"\n      }\n    ]\n  }\n]\n

"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#utility-functions","title":"Utility Functions","text":"
const formatDuration = (seconds: number): string => {\n  const hours = Math.floor(seconds / 3600);\n  const minutes = Math.floor((seconds % 3600) / 60);\n\n  if (hours > 0) {\n    return `${hours}h ${minutes}m`;\n  }\n  return `${minutes}m`;\n};\n\nconst getEventColor = (eventType: string): string => {\n  switch (eventType) {\n    case 'session_start': return '#52c41a';\n    case 'session_end': return '#f5222d';\n    case 'visit': return '#1890ff';\n    case 'location_added': return '#722ed1';\n    default: return '#8c8c8c';\n  }\n};\n
"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/","title":"Volunteer Map Page","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/volunteer/VolunteerMapPage.tsx (570 lines)

Route: /volunteer/canvass/:cutId

Role Requirements: Authenticated users (USER or TEMP role)

Purpose: Full-screen GPS-enabled canvassing map for door-to-door volunteer work with visit recording, route navigation, location management, and session tracking.

Key Features:

Layout: No layout wrapper - full viewport with custom overlays

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#1-gps-tracking","title":"1. GPS Tracking","text":"

Real-time position tracking with following mode:

Components: - GPSTracker component (useEffect with watchPosition) - Blue pulsing circle marker at user's position - Accuracy circle (outer ring) - GPS path polyline (breadcrumb trail) - Follow mode toggle (auto-pan to user)

Code:

<GPSTracker\n  enabled={sessionActive}\n  onPositionUpdate={(position) => {\n    setUserPosition(position);\n    if (followMode) {\n      map.panTo(position.coords);\n    }\n    // Track position for session\n    trackPosition(position);\n  }}\n  onError={(error) => {\n    message.error('GPS unavailable: ' + error.message);\n  }}\n/>\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#2-session-management","title":"2. Session Management","text":"

Canvass session lifecycle:

States: - ACTIVE: Session in progress - PAUSED: Session paused (GPS stopped) - ENDED: Session completed

Controls: - Start Session button (green) - Pause Session button (yellow) - End Session button (red, with confirmation) - Auto-pause after 30min inactivity

Session Bar:

<VolunteerSessionBar\n  session={activeSession}\n  onPause={handlePauseSession}\n  onEnd={() => setEndModalVisible(true)}\n  style={{\n    position: 'fixed',\n    bottom: 60,\n    left: 0,\n    right: 0,\n    zIndex: 1000\n  }}\n/>\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#3-walking-route-display","title":"3. Walking Route Display","text":"

Optimized door-to-door route:

Algorithm: - Nearest neighbor with deduplication - Starts from user's current position - Visits unvisited locations in order - Avoids backtracking

Visual: - Blue polyline connecting locations - Dashed line style - Toggle button to show/hide - Route recalculates when locations visited

Code:

{routeVisible && walkingRoute && (\n  <WalkingRouteLine\n    route={walkingRoute}\n    userPosition={userPosition}\n  />\n)}\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#4-canvass-markers","title":"4. Canvass Markers","text":"

Location markers with clustering:

CanvassMarkerGroup Component: - Clusters nearby markers (radius: 50px) - Color-coded by support level - Click to open VisitRecordingForm - Shows last visit outcome if visited - Purple markers for multi-unit buildings

Marker States: - Unvisited: Gray circle - Visited: Color-coded by outcome - Selected: Larger radius + pulsing animation

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#5-visit-recording-form","title":"5. Visit Recording Form","text":"

Bottom drawer for recording visits:

Fields: - Address (read-only, pre-filled) - Outcome (dropdown: 7 options) - Notes (TextArea, optional) - Contact Interest (checkbox) - Follow-up Required (checkbox)

Outcome Options: 1. Strong Support 2. Leaning Support 3. Undecided 4. Leaning Opposed 5. Opposed 6. No Answer 7. Not Home

Submission: - Creates CanvassVisit record - Updates location supportLevel - Closes drawer - Marker updates color - Next door button finds new nearest

Code:

<VisitRecordingForm\n  location={selectedLocation}\n  sessionId={activeSession?.id}\n  visible={recordingDrawerVisible}\n  onClose={() => setRecordingDrawerVisible(false)}\n  onSubmit={handleVisitSubmit}\n/>\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#6-add-location-mode","title":"6. Add Location Mode","text":"

Crosshair interface for adding missing addresses:

Activation: - \"Add Location\" button in menu - Opens AddLocationDrawer - Crosshair appears at map center - User pans map to position crosshair - \"Tap Here to Add\" button

AddLocationDrawer: - Address input (with geocoding suggestion) - Unit number (for multi-unit buildings) - Notes - Cancel / Confirm buttons

Code:

<AddLocationDrawer\n  visible={addLocationMode}\n  position={map.getCenter()}\n  onConfirm={handleAddLocation}\n  onCancel={() => setAddLocationMode(false)}\n/>\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#7-map-controls","title":"7. Map Controls","text":"

Floating control panels:

VolunteerMapDrawer (left side): - Menu button (hamburger) - Session stats (visits today, doors knocked) - Session picker dropdown - Tile layer toggle - Cut overlays toggle - Address search - Add location button - End session button

Control Buttons (right side): - Geolocate (find my location) - Toggle walking route - Next door (find nearest unvisited) - Fullscreen toggle

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#8-tile-layer-toggle","title":"8. Tile Layer Toggle","text":"

Three basemap options:

  1. OpenStreetMap: Default, detailed streets
  2. CARTO Dark: High contrast, good for day/night
  3. Satellite: Aerial imagery from Esri

Component:

<TileLayerToggle\n  activeLayer={activeLayer}\n  onChange={setActiveLayer}\n  position=\"topright\"\n/>\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#9-address-search-overlay","title":"9. Address Search Overlay","text":"

Quick location lookup:

Features: - Input with search icon - Autocomplete from locations in cut - Fly to location on select - Opens visit recording form

Code:

<AddressSearchOverlay\n  locations={locations}\n  onSelect={(location) => {\n    map.flyTo([location.latitude, location.longitude], 18);\n    setSelectedLocation(location);\n    setRecordingDrawerVisible(true);\n  }}\n/>\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#10-next-door-button","title":"10. Next Door Button","text":"

Intelligent location finder:

Algorithm: 1. Filter unvisited locations in cut 2. Calculate distance from user position 3. Sort by distance (haversine) 4. Select nearest 5. Pan map and open recording form

Code:

const handleNextDoor = () => {\n  const unvisited = locations.filter(loc => \n    !visits.some(v => v.locationId === loc.id)\n  );\n\n  if (unvisited.length === 0) {\n    message.info('All locations visited!');\n    return;\n  }\n\n  const nearest = unvisited.reduce((prev, curr) => {\n    const prevDist = haversineDistance(userPosition, prev);\n    const currDist = haversineDistance(userPosition, curr);\n    return currDist < prevDist ? curr : prev;\n  });\n\n  map.flyTo([nearest.latitude, nearest.longitude], 18);\n  setSelectedLocation(nearest);\n  setRecordingDrawerVisible(true);\n};\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#11-admin-edit-mode","title":"11. Admin Edit Mode","text":"

MAP_ADMIN users can edit locations:

Features: - Edit button on location popup - LocationEditDrawer with full form - Update address, support level, notes - Delete location (with confirmation) - Move location (drag marker)

Conditional Render:

{user?.role === 'MAP_ADMIN' && (\n  <Button onClick={() => setEditMode(true)}>\n    Edit Location\n  </Button>\n)}\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#12-cut-overlay-toggle","title":"12. Cut Overlay Toggle","text":"

Show/hide cut boundaries:

Component:

<CutOverlayControls\n  cuts={cuts}\n  visibleCuts={visibleCuts}\n  onToggle={(cutId) => {\n    setVisibleCuts(prev => {\n      const next = new Set(prev);\n      if (next.has(cutId)) {\n        next.delete(cutId);\n      } else {\n        next.add(cutId);\n      }\n      return next;\n    });\n  }}\n  position=\"bottomleft\"\n/>\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#starting-a-canvass-session","title":"Starting a Canvass Session","text":"
  1. Volunteer navigates to /volunteer/canvass/:cutId
  2. Map loads centered on cut bounds
  3. Locations load within cut
  4. Volunteer clicks \"Start Session\" in drawer
  5. GPS tracking activates
  6. Session bar appears at bottom (above footer)
  7. Walking route calculates and displays
  8. User position marker appears and updates
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#recording-a-visit","title":"Recording a Visit","text":"
  1. Volunteer walks to address
  2. Clicks marker or uses \"Next Door\" button
  3. VisitRecordingForm opens in bottom drawer
  4. Volunteer selects outcome from dropdown
  5. Volunteer adds notes (optional)
  6. Volunteer checks \"Follow-up Required\" if needed
  7. Volunteer clicks \"Save Visit\"
  8. API creates CanvassVisit record
  9. Marker updates to color-coded outcome
  10. Drawer closes
  11. Walking route recalculates
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#adding-a-missing-location","title":"Adding a Missing Location","text":"
  1. Volunteer encounters unlisted address
  2. Opens menu drawer
  3. Clicks \"Add Location\"
  4. Crosshair appears at map center
  5. Volunteer pans map to position crosshair over address
  6. Clicks \"Tap Here to Add\"
  7. AddLocationDrawer opens
  8. Volunteer enters address
  9. Volunteer clicks \"Confirm\"
  10. API creates Location record
  11. New marker appears on map
  12. Volunteer can immediately record visit
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#finding-next-door","title":"Finding Next Door","text":"
  1. Volunteer finishes current visit
  2. Clicks \"Next Door\" button (right side)
  3. Algorithm finds nearest unvisited location
  4. Map animates (flyTo) to location
  5. VisitRecordingForm opens automatically
  6. Volunteer records visit
  7. Repeats process
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#ending-session","title":"Ending Session","text":"
  1. Volunteer clicks \"End Session\" in drawer
  2. Confirmation modal appears
  3. Modal shows session stats (duration, visits, distance)
  4. Volunteer clicks \"End Session\" confirm button
  5. GPS tracking stops
  6. Session marked as ENDED in database
  7. Session bar disappears
  8. Volunteer can start new session or navigate away
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#component-structure","title":"Component Structure","text":"
import React, { useState, useEffect, useCallback } from 'react';\nimport { useParams } from 'react-router-dom';\nimport { MapContainer, TileLayer, useMap } from 'react-leaflet';\nimport { Button, message, Modal } from 'antd';\nimport {\n  AimOutlined,\n  PlusOutlined,\n  ArrowRightOutlined,\n  FullscreenOutlined\n} from '@ant-design/icons';\nimport GPSTracker from '../../components/canvass/GPSTracker';\nimport CanvassMarkerGroup from '../../components/canvass/CanvassMarkerGroup';\nimport WalkingRouteLine from '../../components/canvass/WalkingRouteLine';\nimport VisitRecordingForm from '../../components/canvass/VisitRecordingForm';\nimport AddLocationDrawer from '../../components/canvass/AddLocationDrawer';\nimport VolunteerMapDrawer from '../../components/canvass/VolunteerMapDrawer';\nimport VolunteerFooterNav from '../../components/canvass/VolunteerFooterNav';\nimport VolunteerSessionBar from '../../components/canvass/VolunteerSessionBar';\nimport { useCanvassStore } from '../../stores/canvass.store';\nimport { api } from '../../lib/api';\nimport 'leaflet/dist/leaflet.css';\n\nconst VolunteerMapPage: React.FC = () => {\n  const { cutId } = useParams<{ cutId: string }>();\n  const [map, setMap] = useState<L.Map | null>(null);\n\n  // Canvass store\n  const {\n    activeSession,\n    locations,\n    visits,\n    walkingRoute,\n    userPosition,\n    setActiveSession,\n    addVisit,\n    updateLocation,\n    setUserPosition\n  } = useCanvassStore();\n\n  // UI state\n  const [recordingDrawerVisible, setRecordingDrawerVisible] = useState(false);\n  const [selectedLocation, setSelectedLocation] = useState<Location | null>(null);\n  const [addLocationMode, setAddLocationMode] = useState(false);\n  const [drawerVisible, setDrawerVisible] = useState(false);\n  const [routeVisible, setRouteVisible] = useState(true);\n  const [followMode, setFollowMode] = useState(true);\n\n  // Fetch locations in cut\n  useEffect(() => {\n    const fetchLocations = async () => {\n      try {\n        const response = await api.get(`/api/map/canvass/locations/${cutId}`);\n        // Store in Zustand\n      } catch (error) {\n        message.error('Failed to load locations');\n      }\n    };\n\n    fetchLocations();\n  }, [cutId]);\n\n  return (\n    <div style={{ height: '100vh', width: '100vw', position: 'relative' }}>\n      <MapContainer\n        center={[45.5017, -73.5673]}\n        zoom={16}\n        zoomControl={false}\n        style={{ height: '100%', width: '100%' }}\n        whenCreated={setMap}\n      >\n        <TileLayer\n          url=\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\"\n          attribution='OSM'\n        />\n\n        <GPSTracker\n          enabled={!!activeSession}\n          onPositionUpdate={handlePositionUpdate}\n        />\n\n        <CanvassMarkerGroup\n          locations={locations}\n          visits={visits}\n          onMarkerClick={handleMarkerClick}\n        />\n\n        {routeVisible && walkingRoute && (\n          <WalkingRouteLine\n            route={walkingRoute}\n            userPosition={userPosition}\n          />\n        )}\n      </MapContainer>\n\n      <VolunteerMapDrawer\n        visible={drawerVisible}\n        onClose={() => setDrawerVisible(false)}\n        onStartSession={handleStartSession}\n        onEndSession={() => setEndModalVisible(true)}\n        onAddLocation={() => setAddLocationMode(true)}\n        session={activeSession}\n        stats={sessionStats}\n      />\n\n      <VisitRecordingForm\n        location={selectedLocation}\n        sessionId={activeSession?.id}\n        visible={recordingDrawerVisible}\n        onClose={() => setRecordingDrawerVisible(false)}\n        onSubmit={handleVisitSubmit}\n      />\n\n      <AddLocationDrawer\n        visible={addLocationMode}\n        position={map?.getCenter()}\n        onConfirm={handleAddLocation}\n        onCancel={() => setAddLocationMode(false)}\n      />\n\n      {activeSession && (\n        <VolunteerSessionBar\n          session={activeSession}\n          onPause={handlePause}\n          onEnd={() => setEndModalVisible(true)}\n        />\n      )}\n\n      <VolunteerFooterNav activeKey=\"canvass\" />\n\n      {/* Floating controls */}\n      <div style={{ position: 'absolute', right: 16, top: 16, zIndex: 1000 }}>\n        <Button\n          icon={<AimOutlined />}\n          onClick={handleGeolocate}\n          size=\"large\"\n          style={{ display: 'block', marginBottom: 8 }}\n        />\n        <Button\n          icon={<ArrowRightOutlined />}\n          onClick={handleNextDoor}\n          size=\"large\"\n        />\n      </div>\n    </div>\n  );\n};\n\nexport default VolunteerMapPage;\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#zustand-store-canvassstorets","title":"Zustand Store (canvass.store.ts)","text":"
interface CanvassState {\n  // Session\n  activeSession: CanvassSession | null;\n  setActiveSession: (session: CanvassSession | null) => void;\n\n  // Locations\n  locations: CanvassLocation[];\n  setLocations: (locations: CanvassLocation[]) => void;\n  updateLocation: (id: string, updates: Partial<CanvassLocation>) => void;\n\n  // Visits\n  visits: CanvassVisit[];\n  addVisit: (visit: CanvassVisit) => void;\n\n  // Route\n  walkingRoute: WalkingRoute | null;\n  calculateRoute: () => void;\n\n  // GPS\n  userPosition: GPSPosition | null;\n  setUserPosition: (position: GPSPosition) => void;\n  gpsPath: GPSPosition[];\n  addGPSPoint: (position: GPSPosition) => void;\n}\n\nexport const useCanvassStore = create<CanvassState>((set, get) => ({\n  activeSession: null,\n  locations: [],\n  visits: [],\n  walkingRoute: null,\n  userPosition: null,\n  gpsPath: [],\n\n  setActiveSession: (session) => {\n    set({ activeSession: session });\n    if (session) {\n      get().calculateRoute();\n    }\n  },\n\n  addVisit: (visit) => {\n    set((state) => ({\n      visits: [...state.visits, visit]\n    }));\n    get().calculateRoute(); // Recalculate after visit\n  },\n\n  calculateRoute: () => {\n    const { locations, visits, userPosition } = get();\n\n    if (!userPosition) return;\n\n    const unvisited = locations.filter(loc =>\n      !visits.some(v => v.locationId === loc.id)\n    );\n\n    const route = calculateWalkingRoute(userPosition, unvisited);\n    set({ walkingRoute: route });\n  }\n}));\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#component-state","title":"Component State","text":"
// UI state\nconst [recordingDrawerVisible, setRecordingDrawerVisible] = useState(false);\nconst [selectedLocation, setSelectedLocation] = useState<Location | null>(null);\nconst [addLocationMode, setAddLocationMode] = useState(false);\nconst [drawerVisible, setDrawerVisible] = useState(false);\nconst [endModalVisible, setEndModalVisible] = useState(false);\n\n// Map state\nconst [map, setMap] = useState<L.Map | null>(null);\nconst [routeVisible, setRouteVisible] = useState(true);\nconst [followMode, setFollowMode] = useState(true);\nconst [activeLayer, setActiveLayer] = useState<'osm' | 'carto' | 'satellite'>('osm');\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#endpoints","title":"Endpoints","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#1-get-locations-in-cut","title":"1. Get Locations in Cut","text":"
GET /api/map/canvass/locations/:cutId\nAuthorization: Bearer {token}\n

Response:

[\n  {\n    \"id\": \"cm1loc123\",\n    \"address\": \"123 Main St\",\n    \"latitude\": 45.5017,\n    \"longitude\": -73.5673,\n    \"supportLevel\": null,\n    \"lastVisitDate\": null,\n    \"isMultiUnit\": false\n  }\n]\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#2-start-session","title":"2. Start Session","text":"
POST /api/map/canvass/sessions/start\nAuthorization: Bearer {token}\nContent-Type: application/json\n\n{\n  \"cutId\": \"cm1cut123\"\n}\n

Response:

{\n  \"sessionId\": \"cm2session456\",\n  \"startTime\": \"2025-02-12T10:00:00.000Z\",\n  \"status\": \"ACTIVE\"\n}\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#3-record-visit","title":"3. Record Visit","text":"
POST /api/map/canvass/visits\nAuthorization: Bearer {token}\nContent-Type: application/json\n\n{\n  \"sessionId\": \"cm2session456\",\n  \"locationId\": \"cm1loc123\",\n  \"outcome\": \"strong_support\",\n  \"notes\": \"Very enthusiastic, requested yard sign\",\n  \"contactInterested\": true,\n  \"followUpRequired\": false\n}\n

Response:

{\n  \"visitId\": \"cm3visit789\",\n  \"createdAt\": \"2025-02-12T10:15:00.000Z\"\n}\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#4-track-gps-position","title":"4. Track GPS Position","text":"
POST /api/map/canvass/sessions/:sessionId/track\nAuthorization: Bearer {token}\nContent-Type: application/json\n\n{\n  \"latitude\": 45.5017,\n  \"longitude\": -73.5673,\n  \"accuracy\": 12.5,\n  \"timestamp\": \"2025-02-12T10:15:30.000Z\"\n}\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#5-add-location","title":"5. Add Location","text":"
POST /api/map/locations\nAuthorization: Bearer {token}\nContent-Type: application/json\n\n{\n  \"address\": \"125 Main St\",\n  \"latitude\": 45.5018,\n  \"longitude\": -73.5672,\n  \"cutId\": \"cm1cut123\",\n  \"notes\": \"Added during canvass\"\n}\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#6-end-session","title":"6. End Session","text":"
POST /api/map/canvass/sessions/:sessionId/end\nAuthorization: Bearer {token}\n

Response:

{\n  \"sessionId\": \"cm2session456\",\n  \"endTime\": \"2025-02-12T12:00:00.000Z\",\n  \"duration\": 7200,\n  \"visitCount\": 23,\n  \"distance\": 2834\n}\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#gps-tracker-component","title":"GPS Tracker Component","text":"
// components/canvass/GPSTracker.tsx\nconst GPSTracker: React.FC<{\n  enabled: boolean;\n  onPositionUpdate: (position: GeolocationPosition) => void;\n  onError?: (error: GeolocationPositionError) => void;\n}> = ({ enabled, onPositionUpdate, onError }) => {\n  useEffect(() => {\n    if (!enabled || !navigator.geolocation) return;\n\n    const watchId = navigator.geolocation.watchPosition(\n      onPositionUpdate,\n      onError,\n      {\n        enableHighAccuracy: true,\n        maximumAge: 5000,\n        timeout: 10000\n      }\n    );\n\n    return () => navigator.geolocation.clearWatch(watchId);\n  }, [enabled, onPositionUpdate, onError]);\n\n  return null;\n};\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#walking-route-calculation","title":"Walking Route Calculation","text":"
// utils/walking-route.ts\nexport const calculateWalkingRoute = (\n  start: GPSPosition,\n  locations: CanvassLocation[]\n): WalkingRoute => {\n  const unvisited = [...locations];\n  const route: CanvassLocation[] = [];\n  let current = { latitude: start.latitude, longitude: start.longitude };\n\n  // Nearest neighbor algorithm\n  while (unvisited.length > 0) {\n    let nearestIdx = 0;\n    let nearestDist = Infinity;\n\n    unvisited.forEach((loc, idx) => {\n      const dist = haversineDistance(current, loc);\n      if (dist < nearestDist) {\n        nearestDist = dist;\n        nearestIdx = idx;\n      }\n    });\n\n    const nearest = unvisited.splice(nearestIdx, 1)[0];\n    route.push(nearest);\n    current = nearest;\n  }\n\n  return {\n    locations: route,\n    totalDistance: calculateTotalDistance(route)\n  };\n};\n\nconst haversineDistance = (\n  a: { latitude: number; longitude: number },\n  b: { latitude: number; longitude: number }\n): number => {\n  const R = 6371e3; // Earth radius in meters\n  const \u03c61 = (a.latitude * Math.PI) / 180;\n  const \u03c62 = (b.latitude * Math.PI) / 180;\n  const \u0394\u03c6 = ((b.latitude - a.latitude) * Math.PI) / 180;\n  const \u0394\u03bb = ((b.longitude - a.longitude) * Math.PI) / 180;\n\n  const a1 = Math.sin(\u0394\u03c6 / 2) * Math.sin(\u0394\u03c6 / 2) +\n          Math.cos(\u03c61) * Math.cos(\u03c62) *\n          Math.sin(\u0394\u03bb / 2) * Math.sin(\u0394\u03bb / 2);\n  const c = 2 * Math.atan2(Math.sqrt(a1), Math.sqrt(1 - a1));\n\n  return R * c; // Distance in meters\n};\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#canvass-marker-group","title":"Canvass Marker Group","text":"
// components/canvass/CanvassMarkerGroup.tsx\nconst CanvassMarkerGroup: React.FC<{\n  locations: CanvassLocation[];\n  visits: CanvassVisit[];\n  onMarkerClick: (location: CanvassLocation) => void;\n}> = ({ locations, visits, onMarkerClick }) => {\n  const getMarkerColor = (location: CanvassLocation) => {\n    const visit = visits.find(v => v.locationId === location.id);\n\n    if (!visit) return '#8c8c8c'; // Unvisited\n\n    switch (visit.outcome) {\n      case 'strong_support': return '#52c41a';\n      case 'leaning_support': return '#95de64';\n      case 'undecided': return '#fadb14';\n      case 'leaning_opposed': return '#ff7a45';\n      case 'opposed': return '#f5222d';\n      case 'no_answer': return '#8c8c8c';\n      case 'not_home': return '#d9d9d9';\n      default: return '#8c8c8c';\n    }\n  };\n\n  return (\n    <>\n      {locations.map(location => (\n        <CircleMarker\n          key={location.id}\n          center={[location.latitude, location.longitude]}\n          radius={10}\n          pathOptions={{\n            color: 'white',\n            weight: 2,\n            fillColor: getMarkerColor(location),\n            fillOpacity: 0.8\n          }}\n          eventHandlers={{\n            click: () => onMarkerClick(location)\n          }}\n        >\n          <Popup>\n            <Text strong>{location.address}</Text>\n            {location.unitNumber && (\n              <>\n                <br />\n                <Text>Unit: {location.unitNumber}</Text>\n              </>\n            )}\n          </Popup>\n        </CircleMarker>\n      ))}\n    </>\n  );\n};\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#performance-considerations","title":"Performance Considerations","text":"
  1. Zustand Store: Global state prevents prop drilling
  2. Debounced GPS: Position tracked every 5 seconds (not every update)
  3. Route Recalc: Only recalculates when visits added
  4. Marker Clustering: Reduces DOM nodes on dense maps
  5. Lazy Drawers: Components mount only when opened
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#issue-gps-not-working","title":"Issue: GPS Not Working","text":"

Causes: 1. HTTPS required (geolocation API restriction) 2. User denied permission 3. GPS unavailable (indoors, bad signal)

Solutions:

const handleGPSError = (error: GeolocationPositionError) => {\n  switch (error.code) {\n    case error.PERMISSION_DENIED:\n      Modal.error({\n        title: 'GPS Permission Required',\n        content: 'Please enable location permissions in your browser settings.'\n      });\n      break;\n    case error.POSITION_UNAVAILABLE:\n      message.warning('GPS unavailable. Try moving outdoors.');\n      break;\n    case error.TIMEOUT:\n      message.warning('GPS timeout. Check your device settings.');\n      break;\n  }\n};\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#issue-route-not-displaying","title":"Issue: Route Not Displaying","text":"

Causes: 1. No unvisited locations 2. Route calculation error 3. Route toggle off

Solutions:

// Add debug logging\nuseEffect(() => {\n  console.log('Route calculation:', {\n    unvisited: locations.filter(l => !visits.some(v => v.locationId === l.id)).length,\n    routeVisible,\n    walkingRoute: walkingRoute?.locations.length\n  });\n}, [locations, visits, routeVisible, walkingRoute]);\n\n// Show message if no unvisited\nif (unvisitedCount === 0) {\n  message.success('All locations visited! Great work!');\n}\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#issue-session-not-ending","title":"Issue: Session Not Ending","text":"

Causes: 1. API timeout 2. Pending GPS uploads 3. Network disconnection

Solutions:

const handleEndSession = async () => {\n  try {\n    // Upload any pending GPS points first\n    await uploadPendingGPSPoints();\n\n    // Then end session\n    await api.post(`/api/map/canvass/sessions/${activeSession.id}/end`, {}, {\n      timeout: 10000\n    });\n\n    message.success('Session ended');\n    setActiveSession(null);\n\n  } catch (error: any) {\n    if (error.code === 'ECONNABORTED') {\n      // Force local end if server timeout\n      setActiveSession(null);\n      message.warning('Session ended locally (server unreachable)');\n    } else {\n      message.error('Failed to end session');\n    }\n  }\n};\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/","title":"Volunteer Shifts Page","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/volunteer/VolunteerShiftsPage.tsx (312 lines)

Route: /volunteer/assignments

Role Requirements: Authenticated users (USER or TEMP role)

Purpose: Volunteer-facing shift management showing upcoming shifts and personal signups with signup/cancel functionality.

Key Features:

Layout: Uses VolunteerLayout with top navigation

"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#1-segmented-tabs","title":"1. Segmented Tabs","text":"
<Segmented\n  value={activeTab}\n  onChange={setActiveTab}\n  options={[\n    { label: 'Upcoming Shifts', value: 'upcoming' },\n    { label: 'My Signups', value: 'signups' }\n  ]}\n  size=\"large\"\n  block\n/>\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#2-shift-cards","title":"2. Shift Cards","text":"

Upcoming Shifts Tab: - Shows all available shifts - \"Sign Up\" button (primary) - \"Signed Up\" badge if user already signed up - \"Cancel Signup\" link if signed up

My Signups Tab: - Shows only user's signups - \"Cancel Signup\" button (danger) - Shift details emphasized

"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#3-capacity-progress-bar","title":"3. Capacity Progress Bar","text":"
const percentage = (shift.currentSignups / shift.maxVolunteers) * 100;\nconst color = percentage < 70 ? 'success' : percentage < 90 ? 'warning' : 'exception';\n\n<Progress percent={percentage} status={color} />\n<Text type=\"secondary\">\n  {shift.currentSignups} of {shift.maxVolunteers} volunteers\n</Text>\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#4-signup-confirmation-modal","title":"4. Signup Confirmation Modal","text":"
<Modal\n  title=\"Confirm Signup\"\n  open={signupModalVisible}\n  onOk={handleSignup}\n  onCancel={() => setSignupModalVisible(false)}\n>\n  <Text>Are you sure you want to sign up for:</Text>\n  <div style={{ marginTop: 16, padding: 16, background: '#f5f5f5' }}>\n    <Text strong>{selectedShift?.title}</Text>\n    <br />\n    <Text>{dayjs(selectedShift?.date).format('MMMM D, YYYY')}</Text>\n    <br />\n    <Text>{selectedShift?.startTime} - {selectedShift?.endTime}</Text>\n  </div>\n</Modal>\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#5-cancel-signup-modal","title":"5. Cancel Signup Modal","text":"
<Modal\n  title=\"Cancel Signup\"\n  open={cancelModalVisible}\n  onOk={handleCancel}\n  onCancel={() => setCancelModalVisible(false)}\n  okText=\"Yes, Cancel Signup\"\n  okButtonProps={{ danger: true }}\n>\n  <Text>Are you sure you want to cancel your signup for this shift?</Text>\n  <Alert\n    type=\"warning\"\n    message=\"This action cannot be undone\"\n    style={{ marginTop: 16 }}\n  />\n</Modal>\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#state-management","title":"State Management","text":"
const [activeTab, setActiveTab] = useState<'upcoming' | 'signups'>('upcoming');\nconst [shifts, setShifts] = useState<Shift[]>([]);\nconst [mySignups, setMySignups] = useState<Shift[]>([]);\nconst [loading, setLoading] = useState(true);\nconst [signupModalVisible, setSignupModalVisible] = useState(false);\nconst [cancelModalVisible, setCancelModalVisible] = useState(false);\nconst [selectedShift, setSelectedShift] = useState<Shift | null>(null);\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#endpoints","title":"Endpoints","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#1-get-upcoming-shifts","title":"1. Get Upcoming Shifts","text":"
GET /api/map/shifts/upcoming\nAuthorization: Bearer {token}\n

Response:

[\n  {\n    \"id\": \"cm1abc123\",\n    \"title\": \"Weekend Canvass\",\n    \"date\": \"2025-02-15\",\n    \"startTime\": \"10:00\",\n    \"endTime\": \"14:00\",\n    \"location\": \"Campaign Office\",\n    \"maxVolunteers\": 10,\n    \"currentSignups\": 7,\n    \"cutId\": \"cm2cut123\",\n    \"cutName\": \"Downtown\",\n    \"isSignedUp\": false\n  }\n]\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#2-get-my-signups","title":"2. Get My Signups","text":"
GET /api/map/shifts/my-signups\nAuthorization: Bearer {token}\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#3-sign-up","title":"3. Sign Up","text":"
POST /api/map/shifts/:id/signup\nAuthorization: Bearer {token}\n

Response:

{\n  \"success\": true,\n  \"signupId\": \"cm3signup456\",\n  \"message\": \"Successfully signed up for shift\"\n}\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#4-cancel-signup","title":"4. Cancel Signup","text":"
DELETE /api/map/shifts/:id/cancel\nAuthorization: Bearer {token}\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#shift-card-upcoming-tab","title":"Shift Card (Upcoming Tab)","text":"
<Card hoverable={!shift.isSignedUp}>\n  {shift.isSignedUp && (\n    <Tag color=\"green\" style={{ position: 'absolute', top: 16, right: 16 }}>\n      Signed Up\n    </Tag>\n  )}\n\n  <Title level={4}>{shift.title}</Title>\n\n  <Space direction=\"vertical\" size={8} style={{ width: '100%', marginBottom: 16 }}>\n    <Text><CalendarOutlined /> {dayjs(shift.date).format('MMMM D, YYYY')}</Text>\n    <Text><ClockCircleOutlined /> {shift.startTime} - {shift.endTime}</Text>\n    <Text><EnvironmentOutlined /> {shift.location}</Text>\n    {shift.cutName && <Tag color=\"blue\">{shift.cutName}</Tag>}\n  </Space>\n\n  <Progress\n    percent={(shift.currentSignups / shift.maxVolunteers) * 100}\n    strokeColor={shift.currentSignups < shift.maxVolunteers ? '#52c41a' : '#f5222d'}\n    showInfo={false}\n    style={{ marginBottom: 16 }}\n  />\n\n  {shift.isSignedUp ? (\n    <Button\n      danger\n      block\n      onClick={() => {\n        setSelectedShift(shift);\n        setCancelModalVisible(true);\n      }}\n    >\n      Cancel Signup\n    </Button>\n  ) : (\n    <Button\n      type=\"primary\"\n      block\n      disabled={shift.currentSignups >= shift.maxVolunteers}\n      onClick={() => {\n        setSelectedShift(shift);\n        setSignupModalVisible(true);\n      }}\n    >\n      {shift.currentSignups >= shift.maxVolunteers ? 'Full' : 'Sign Up'}\n    </Button>\n  )}\n</Card>\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#my-signups-tab","title":"My Signups Tab","text":"
{activeTab === 'signups' && (\n  <>\n    {mySignups.length === 0 ? (\n      <Empty\n        description=\"You haven't signed up for any shifts yet\"\n        image={Empty.PRESENTED_IMAGE_SIMPLE}\n      />\n    ) : (\n      <Row gutter={[16, 16]}>\n        {mySignups.map(shift => (\n          <Col xs={24} sm={12} key={shift.id}>\n            <Card>\n              <Title level={4}>{shift.title}</Title>\n              {/* Shift details */}\n              <Button\n                danger\n                block\n                onClick={() => handleCancelClick(shift)}\n              >\n                Cancel Signup\n              </Button>\n            </Card>\n          </Col>\n        ))}\n      </Row>\n    )}\n  </>\n)}\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#performance-considerations","title":"Performance Considerations","text":"
  1. Parallel Fetches: Upcoming shifts and signups fetched simultaneously
  2. Optimistic Updates: Signup/cancel updates UI immediately
  3. Tab State: No refetch when switching tabs (cached)
  4. Debounced Modals: Prevent double-submission with loading state
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#issue-signed-up-badge-not-showing","title":"Issue: Signed Up Badge Not Showing","text":"

Cause: isSignedUp field not populated by API

Solution:

// Backend: Include isSignedUp in shift query\nconst shifts = await prisma.shift.findMany({\n  where: { date: { gte: new Date() } },\n  include: {\n    signups: {\n      where: { userId: req.user!.id },\n      select: { id: true }\n    }\n  }\n});\n\n// Map to include isSignedUp\nreturn shifts.map(shift => ({\n  ...shift,\n  isSignedUp: shift.signups.length > 0\n}));\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#issue-cancel-not-refreshing-list","title":"Issue: Cancel Not Refreshing List","text":"

Solution:

const handleCancel = async () => {\n  try {\n    await api.delete(`/api/map/shifts/${selectedShift.id}/cancel`);\n    message.success('Signup cancelled');\n\n    // Refresh both lists\n    const [upcomingRes, signupsRes] = await Promise.all([\n      api.get('/api/map/shifts/upcoming'),\n      api.get('/api/map/shifts/my-signups')\n    ]);\n\n    setShifts(upcomingRes.data);\n    setMySignups(signupsRes.data);\n\n  } catch (error) {\n    message.error('Failed to cancel signup');\n  } finally {\n    setCancelModalVisible(false);\n  }\n};\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/getting-started/","title":"Getting Started with Changemaker Lite V2","text":"

Welcome to Changemaker Lite V2! This guide will help you get up and running quickly with your self-hosted political campaign platform.

"},{"location":"v2/getting-started/#what-is-changemaker-lite-v2","title":"What is Changemaker Lite V2?","text":"

Changemaker Lite V2 is a complete rebuild of the platform with a modern TypeScript stack, offering:

"},{"location":"v2/getting-started/#prerequisites","title":"Prerequisites","text":"

Before you begin, ensure you have:

"},{"location":"v2/getting-started/#optional-but-recommended","title":"Optional but Recommended","text":""},{"location":"v2/getting-started/#quick-start-options","title":"Quick Start Options","text":"

Choose your path based on your needs:

"},{"location":"v2/getting-started/#option-1-quick-start-5-minutes","title":"Option 1: Quick Start (5 Minutes)","text":"

Get the platform running locally for evaluation and testing.

\u2192 Quick Start Guide

"},{"location":"v2/getting-started/#option-2-full-installation-30-minutes","title":"Option 2: Full Installation (30 Minutes)","text":"

Set up for production with custom configuration, monitoring, and backups.

\u2192 Full Installation Guide

"},{"location":"v2/getting-started/#option-3-local-development-45-minutes","title":"Option 3: Local Development (45 Minutes)","text":"

Set up a complete development environment with hot reload and debugging.

\u2192 Development Setup

"},{"location":"v2/getting-started/#architecture-overview","title":"Architecture Overview","text":"

Changemaker Lite V2 uses a microservices architecture:

graph LR\n    User[User Browser] --> Nginx[Nginx<br/>Reverse Proxy]\n    Nginx --> Admin[Admin GUI<br/>React]\n    Nginx --> ExpressAPI[Express API<br/>Main Features]\n    Nginx --> FastifyAPI[Fastify API<br/>Media Library]\n    ExpressAPI --> DB[(PostgreSQL)]\n    FastifyAPI --> DB\n    ExpressAPI --> Redis[(Redis)]\n    FastifyAPI --> Redis

Key Components:

Learn more about the architecture \u2192

"},{"location":"v2/getting-started/#whats-next","title":"What's Next?","text":"

After installation, you'll want to:

  1. First Login - Access the admin interface and change default credentials
  2. Environment Configuration - Customize your .env file for your needs
  3. Docker Management - Learn to start, stop, and manage services
  4. Admin Guide - Platform administration workflows
"},{"location":"v2/getting-started/#common-installation-issues","title":"Common Installation Issues","text":"

If you encounter problems during setup, check our troubleshooting guides:

"},{"location":"v2/getting-started/#getting-help","title":"Getting Help","text":""},{"location":"v2/getting-started/#feature-highlights","title":"Feature Highlights","text":""},{"location":"v2/getting-started/#influence-module","title":"Influence Module","text":"

Run sophisticated email advocacy campaigns with: - Multi-target campaigns (MPs, MPPs, councillors) - Public response walls with moderation - Email queue with retry logic - Tracking and analytics

"},{"location":"v2/getting-started/#map-module","title":"Map Module","text":"

Coordinate field operations with: - Multi-provider geocoding (6 services) - Territory management (cuts) - GPS-tracked canvassing - Printable walk sheets with QR codes

"},{"location":"v2/getting-started/#landing-pages","title":"Landing Pages","text":"

Build custom public pages with: - GrapesJS drag-and-drop editor - MkDocs export for static sites - Mobile-responsive templates

"},{"location":"v2/getting-started/#monitoring","title":"Monitoring","text":"

Keep your platform healthy with: - Real-time metrics dashboards - Custom alerts - Service health monitoring - Data quality tracking

Explore all features \u2192

Ready to get started? Choose your installation path above!

"},{"location":"v2/getting-started/quick-start/","title":"Quick Start Guide","text":"

Get Changemaker Lite V2 running in 5 minutes with this streamlined guide.

For Evaluation Only

This quick start uses default credentials and minimal configuration. Do not use in production without following the Full Installation Guide and changing all default passwords.

"},{"location":"v2/getting-started/quick-start/#step-1-clone-the-repository","title":"Step 1: Clone the Repository","text":"
git clone <repo-url> changemaker.lite\ncd changemaker.lite\ngit checkout v2\n

Tip

If you're evaluating locally, you can skip the domain configuration and use localhost URLs.

"},{"location":"v2/getting-started/quick-start/#step-2-create-environment-file","title":"Step 2: Create Environment File","text":"
cp .env.example .env\n

Edit .env and set the minimum required variables:

# Database\nV2_POSTGRES_PASSWORD=your_secure_password_here\n\n# Redis\nREDIS_PASSWORD=another_secure_password\n\n# JWT Secrets (generate with: openssl rand -hex 32)\nJWT_ACCESS_SECRET=<your-access-secret>\nJWT_REFRESH_SECRET=<your-refresh-secret>\n\n# Encryption Key (must differ from JWT secrets)\nENCRYPTION_KEY=<your-encryption-key>\n\n# Email (use test mode for evaluation)\nEMAIL_TEST_MODE=true\n

Generate Secure Secrets

echo \"JWT_ACCESS_SECRET=$(openssl rand -hex 32)\"\necho \"JWT_REFRESH_SECRET=$(openssl rand -hex 32)\"\necho \"ENCRYPTION_KEY=$(openssl rand -hex 32)\"\n
"},{"location":"v2/getting-started/quick-start/#step-3-start-core-services","title":"Step 3: Start Core Services","text":"
# Start database and cache\ndocker compose up -d v2-postgres redis\n\n# Wait for database to be ready (about 10 seconds)\nsleep 10\n\n# Start API and admin\ndocker compose up -d api admin nginx\n
"},{"location":"v2/getting-started/quick-start/#step-4-run-database-migrations","title":"Step 4: Run Database Migrations","text":"
# Run Prisma migrations\ndocker compose exec api npx prisma migrate deploy\n\n# Seed initial data (creates admin user)\ndocker compose exec api npx prisma db seed\n

This creates: - Admin user: admin@example.com / Admin123! - Site settings: Default configuration - Page blocks: Landing page components

"},{"location":"v2/getting-started/quick-start/#step-5-access-the-platform","title":"Step 5: Access the Platform","text":"

Open your browser and navigate to:

Login with: - Email: admin@example.com - Password: Admin123!

Change Default Credentials

Immediately change the default admin password:

  1. Navigate to Settings in the sidebar
  2. Click your profile
  3. Change password to something secure (12+ chars, mixed case, numbers)
"},{"location":"v2/getting-started/quick-start/#step-6-verify-installation","title":"Step 6: Verify Installation","text":"

Check that all services are running:

docker compose ps\n

You should see: - v2-postgres - Database (port 5433) - redis-changemaker - Cache (port 6379) - api - Express API (port 4000) - admin - React admin (port 3000) - nginx - Reverse proxy (port 80)

Test the API:

curl http://localhost:4000/health\n

Expected response:

{\n  \"status\": \"healthy\",\n  \"timestamp\": \"2026-02-11T18:00:00.000Z\"\n}\n

"},{"location":"v2/getting-started/quick-start/#whats-next","title":"What's Next?","text":"

Now that you have Changemaker Lite running:

  1. First Login - Tour the admin interface
  2. Environment Configuration - Customize your setup
  3. Create Your First Campaign - Run an advocacy campaign
  4. Import Locations - Set up your map
"},{"location":"v2/getting-started/quick-start/#optional-start-additional-services","title":"Optional: Start Additional Services","text":""},{"location":"v2/getting-started/quick-start/#email-testing-mailhog","title":"Email Testing (MailHog)","text":"

Capture emails in development without sending real messages:

docker compose up -d mailhog\n

Access at: http://localhost:8025

"},{"location":"v2/getting-started/quick-start/#data-browser-nocodb","title":"Data Browser (NocoDB)","text":"

Read-only database browser:

docker compose up -d nocodb-v2\n

Access at: http://localhost:8091

"},{"location":"v2/getting-started/quick-start/#newsletter-platform-listmonk","title":"Newsletter Platform (Listmonk)","text":"

Email marketing integration:

docker compose up -d listmonk-postgres listmonk listmonk-init\n

Access at: http://localhost:9001

"},{"location":"v2/getting-started/quick-start/#monitoring-stack","title":"Monitoring Stack","text":"

Prometheus + Grafana + Alertmanager:

docker compose --profile monitoring up -d\n

Access: - Grafana: http://localhost:3001 (admin/admin) - Prometheus: http://localhost:9090 - Alertmanager: http://localhost:9093

"},{"location":"v2/getting-started/quick-start/#common-issues","title":"Common Issues","text":""},{"location":"v2/getting-started/quick-start/#port-already-in-use","title":"Port Already in Use","text":"

If you see errors like port is already allocated:

# Check what's using the port\nsudo lsof -i :3000\n\n# Stop the conflicting service or change ports in .env\n
"},{"location":"v2/getting-started/quick-start/#database-connection-failed","title":"Database Connection Failed","text":"
# Check if PostgreSQL is running\ndocker compose ps v2-postgres\n\n# View logs\ndocker compose logs v2-postgres\n\n# Restart database\ndocker compose restart v2-postgres\n
"},{"location":"v2/getting-started/quick-start/#api-wont-start","title":"API Won't Start","text":"
# View API logs\ndocker compose logs api\n\n# Common fix: rebuild the container\ndocker compose build api\ndocker compose up -d api\n

See full troubleshooting guide \u2192

"},{"location":"v2/getting-started/quick-start/#stopping-services","title":"Stopping Services","text":"
# Stop all services\ndocker compose down\n\n# Stop and remove volumes (WARNING: deletes all data)\ndocker compose down -v\n
"},{"location":"v2/getting-started/quick-start/#next-steps-for-production","title":"Next Steps for Production","text":"

This quick start is for evaluation only. Before production deployment:

  1. Full Installation Guide - Production-ready setup
  2. Security Checklist - Harden your installation
  3. Backup Strategy - Protect your data
  4. Tunneling Setup - Public access via Pangolin
  5. Monitoring Configuration - Production observability

Congratulations! You now have Changemaker Lite V2 running locally. Explore the admin interface and check out the User Guides to learn what you can do.

"},{"location":"v2/migration/","title":"Migration Guide: V1 to V2 Overview","text":"

This comprehensive guide covers the complete migration process from Changemaker Lite V1 to V2, including architectural changes, data migration, and rollback procedures.

"},{"location":"v2/migration/#overview","title":"Overview","text":"

Changemaker Lite V2 is a complete rebuild of the platform, not an incremental upgrade. The migration represents a fundamental shift in architecture, technology stack, and approach to campaign management.

"},{"location":"v2/migration/#v1-vs-v2-at-a-glance","title":"V1 vs V2 at a Glance","text":"Aspect V1 V2 Architecture Two separate Express apps Single unified Express + Fastify API Data Layer NocoDB REST API Prisma ORM + PostgreSQL 16 Frontend Embedded EJS templates React SPA (Vite + Ant Design) Authentication Session cookies + bcrypt JWT tokens (access + refresh) API Style REST via NocoDB REST with Zod validation State Management Server-side sessions Zustand client state + JWT Job Queue Bull (Redis) BullMQ (Redis) Database NocoDB tables Prisma migrations Email Nodemailer + Bull BullMQ + Listmonk integration Ports 3333 (influence), 3000 (map) 4000 (API), 3000 (admin)"},{"location":"v2/migration/#why-migrate-to-v2","title":"Why Migrate to V2?","text":""},{"location":"v2/migration/#technical-benefits","title":"Technical Benefits","text":"
  1. Unified Codebase: Single API codebase instead of two separate applications
  2. Type Safety: Full TypeScript coverage with Prisma type generation
  3. Modern Stack: Latest React, Vite build tooling, Ant Design components
  4. Better Performance: Direct database access via Prisma vs REST API abstraction
  5. Improved Security: JWT refresh token rotation, RBAC, comprehensive audit trail
  6. Scalability: Separation of concerns (dual API architecture for media)
  7. Developer Experience: Hot reload, better tooling, comprehensive documentation
"},{"location":"v2/migration/#feature-enhancements","title":"Feature Enhancements","text":"
  1. New Features:
  2. Landing page builder with GrapesJS
  3. Email template system with versioning
  4. Media library with video uploads and reactions
  5. Volunteer canvassing system with GPS tracking
  6. Data quality dashboard for geocoding
  7. Comprehensive monitoring (Prometheus + Grafana)
  8. NAR 2025 electoral data import
  9. Pangolin tunnel integration

  10. Enhanced Existing Features:

  11. Response wall with upvoting and moderation
  12. Multi-provider geocoding (6 providers)
  13. Advanced shift management with cut assignments
  14. Printable walk sheets with QR codes
  15. Listmonk newsletter sync

  16. Improved Admin Experience:

  17. Modern React UI with consistent design
  18. Real-time updates with optimistic UI
  19. Advanced filtering and search
  20. Bulk operations
  21. Responsive mobile support
"},{"location":"v2/migration/#migration-timeline","title":"Migration Timeline","text":""},{"location":"v2/migration/#planned-phases-from-v2_planmd","title":"Planned Phases (from V2_PLAN.md)","text":""},{"location":"v2/migration/#actual-development-timeline","title":"Actual Development Timeline","text":""},{"location":"v2/migration/#migration-duration-estimate","title":"Migration Duration Estimate","text":"Migration Step Duration Downtime Required V1 data export 1-2 hours No Data transformation 2-4 hours No V2 database setup 30 minutes No V2 data import 1-3 hours No Testing & validation 2-4 hours No DNS/service switchover 15 minutes Yes Post-migration verification 1 hour No Total 8-15 hours 15 minutes

Minimize Downtime

Perform all data export, transformation, and testing on a separate V2 staging environment. Only switch production traffic after full validation.

"},{"location":"v2/migration/#risk-assessment","title":"Risk Assessment","text":""},{"location":"v2/migration/#high-risk-areas","title":"High Risk Areas","text":"
  1. Data Loss
  2. Risk: Campaign data, locations, or user accounts lost during migration
  3. Mitigation: Full V1 backup before migration, validation checksums, rollback plan
  4. Impact: High (business-critical data)

  5. Authentication Disruption

  6. Risk: Users unable to login after migration (password hash incompatibility)
  7. Mitigation: Test password migration with sample users, password reset flow ready
  8. Impact: High (blocks all access)

  9. Email Delivery Failure

  10. Risk: Campaign emails stop sending after migration
  11. Mitigation: Test SMTP configuration, BullMQ queue verification, MailHog testing
  12. Impact: High (core feature)
"},{"location":"v2/migration/#medium-risk-areas","title":"Medium Risk Areas","text":"
  1. Representative Data
  2. Risk: Cached representative data doesn't migrate correctly
  3. Mitigation: Cache can be rebuilt from Represent API, non-critical
  4. Impact: Medium (cacheable data)

  5. Location Geocoding

  6. Risk: Geocoded coordinates lost or corrupted
  7. Mitigation: V2 multi-provider geocoding can re-geocode, bulk geocode endpoint
  8. Impact: Medium (can be re-geocoded)

  9. Shift Signups

  10. Risk: Volunteer shift assignments lost
  11. Mitigation: Export signups separately, manual verification, confirmation emails
  12. Impact: Medium (time-sensitive data)
"},{"location":"v2/migration/#low-risk-areas","title":"Low Risk Areas","text":"
  1. Response Wall Data
  2. Risk: Public responses or upvotes lost
  3. Mitigation: CSV export, manual re-entry if needed
  4. Impact: Low (public-facing only)

  5. Custom Settings

  6. Risk: V1 settings don't map to V2 schema
  7. Mitigation: Manual reconfiguration in V2 SettingsPage
  8. Impact: Low (quick to reconfigure)
"},{"location":"v2/migration/#rollback-plan","title":"Rollback Plan","text":""},{"location":"v2/migration/#if-migration-fails","title":"If Migration Fails","text":"
  1. Immediate Actions (within 15 minutes):

    # Stop V2 services\ndocker compose down\n\n# Restore V1 services\ndocker compose -f docker-compose.v1.yml up -d\n\n# Restore DNS (point back to V1)\n# Update tunnel/proxy configuration\n

  2. Data Restoration (if V2 data was modified):

    # Restore V1 database from backup\ndocker compose -f docker-compose.v1.yml exec -T v1-postgres \\\n  psql -U nocodb nocodb < backups/v1-nocodb-backup.sql\n\n# Verify data integrity\ndocker compose -f docker-compose.v1.yml logs -f\n

  3. Verification:

  4. Test V1 login
  5. Verify campaign data visible
  6. Check location map loads
  7. Send test campaign email
  8. Verify response wall displays
"},{"location":"v2/migration/#rollback-window","title":"Rollback Window","text":"

Rollback Deadline

Plan your migration with a clear rollback deadline. After this window, V2 becomes the source of truth.

"},{"location":"v2/migration/#support-resources","title":"Support Resources","text":""},{"location":"v2/migration/#documentation","title":"Documentation","text":""},{"location":"v2/migration/#community-support","title":"Community & Support","text":""},{"location":"v2/migration/#professional-services","title":"Professional Services","text":"

For organizations requiring: - Custom data migration scripts - Zero-downtime migration - Training for administrators - Priority support during migration

Contact: enterprise@cmlite.org

"},{"location":"v2/migration/#prerequisites","title":"Prerequisites","text":"

Before beginning migration, ensure you have:

"},{"location":"v2/migration/#v1-environment","title":"V1 Environment","text":""},{"location":"v2/migration/#v2-environment","title":"V2 Environment","text":""},{"location":"v2/migration/#migration-planning","title":"Migration Planning","text":""},{"location":"v2/migration/#migration-steps-overview","title":"Migration Steps Overview","text":"

This is a high-level overview. Detailed steps are in Data Migration.

"},{"location":"v2/migration/#phase-1-preparation-no-downtime","title":"Phase 1: Preparation (No Downtime)","text":"
  1. Export V1 Data

    # Export all NocoDB tables to JSON\n./scripts/export-v1-data.sh\n\n# Backup file uploads\ntar -czf v1-uploads.tar.gz ./uploads/\n

  2. Set Up V2 Environment

    git checkout v2\ncp .env.example .env\n# Edit .env with V2 configuration\n

  3. Start V2 Services (parallel to V1)

    docker compose up -d v2-postgres redis\ndocker compose exec api npx prisma migrate deploy\n

"},{"location":"v2/migration/#phase-2-data-transformation-no-downtime","title":"Phase 2: Data Transformation (No Downtime)","text":"
  1. Transform V1 Data for V2

    # Run transformation scripts\nnode scripts/transform-users.js\nnode scripts/transform-campaigns.js\nnode scripts/transform-locations.js\n

  2. Import into V2 Database

    # Import transformed data\ndocker compose exec api node scripts/import-data.js\n

  3. Validate Data Integrity

    # Compare record counts\ndocker compose exec api node scripts/validate-migration.js\n

"},{"location":"v2/migration/#phase-3-testing-no-downtime","title":"Phase 3: Testing (No Downtime)","text":"
  1. Test V2 Functionality
  2. Login with test users (verify password migration)
  3. View campaigns, locations, shifts
  4. Submit test response
  5. Send test email
  6. Check admin permissions

  7. Performance Testing

  8. Load campaigns page (check query performance)
  9. Geocode sample addresses
  10. Test map rendering with all locations
  11. Verify Redis caching
"},{"location":"v2/migration/#phase-4-switchover-15-minutes-downtime","title":"Phase 4: Switchover (15 Minutes Downtime)","text":"
  1. Enable Maintenance Mode (V1)

    # Stop V1 services\ndocker compose -f docker-compose.v1.yml down\n

  2. Start V2 Services

    # Start all V2 services\ndocker compose up -d\n

  3. Update DNS/Proxy

    • Point cmlite.org to V2 nginx
    • Update Pangolin tunnel endpoint
    • Verify SSL certificates
"},{"location":"v2/migration/#phase-5-verification-post-migration","title":"Phase 5: Verification (Post-Migration)","text":"
  1. Smoke Tests

    • Admin login works
    • Campaign list loads
    • Location map renders
    • Email sending functional
    • Response wall displays
  2. Monitor for Issues

    # Watch logs for errors\ndocker compose logs -f api admin\n\n# Check metrics\nopen http://localhost:3001  # Grafana\n

  3. Announce Migration Complete

    • Email all users with V2 login URL
    • Update documentation links
    • Monitor support channels
"},{"location":"v2/migration/#post-migration-checklist","title":"Post-Migration Checklist","text":"

After successful migration, complete these tasks:

"},{"location":"v2/migration/#immediate-day-1","title":"Immediate (Day 1)","text":""},{"location":"v2/migration/#first-week","title":"First Week","text":""},{"location":"v2/migration/#first-month","title":"First Month","text":""},{"location":"v2/migration/#common-migration-scenarios","title":"Common Migration Scenarios","text":""},{"location":"v2/migration/#scenario-1-small-organization-1000-locations","title":"Scenario 1: Small Organization (< 1000 locations)","text":""},{"location":"v2/migration/#scenario-2-medium-organization-1000-10000-locations","title":"Scenario 2: Medium Organization (1000-10000 locations)","text":""},{"location":"v2/migration/#scenario-3-large-organization-10000-locations","title":"Scenario 3: Large Organization (10000+ locations)","text":""},{"location":"v2/migration/#scenario-4-active-campaign-during-migration","title":"Scenario 4: Active Campaign During Migration","text":"

Problem: Can't afford downtime during critical campaign period.

Solution: 1. Set up V2 as read-only mirror (import V1 data, disable writes) 2. Continue using V1 for all active operations 3. Schedule final catchup migration after campaign concludes 4. Or: Use blue-green deployment with manual data sync

Active Campaign Warning

Do NOT migrate during active campaign periods. Schedule migration between campaigns or during organizational downtime.

"},{"location":"v2/migration/#migration-validation-checklist","title":"Migration Validation Checklist","text":"

Use this checklist to verify successful migration:

"},{"location":"v2/migration/#data-integrity","title":"Data Integrity","text":""},{"location":"v2/migration/#functional-testing","title":"Functional Testing","text":""},{"location":"v2/migration/#performance-testing","title":"Performance Testing","text":""},{"location":"v2/migration/#security-testing","title":"Security Testing","text":""},{"location":"v2/migration/#troubleshooting-migration-issues","title":"Troubleshooting Migration Issues","text":"

Common problems and solutions:

"},{"location":"v2/migration/#issue-user-login-fails-after-migration","title":"Issue: User Login Fails After Migration","text":"

Symptoms: Users receive \"Invalid credentials\" error despite correct password.

Causes: - Bcrypt hash corruption during export/import - Password field length truncation - Character encoding issues

Solutions:

# Check password hash format in V2\ndocker compose exec api npx prisma studio\n# User table \u2192 password field should start with $2b$\n\n# Reset affected user password\ndocker compose exec api node scripts/reset-password.js user@example.com\n

"},{"location":"v2/migration/#issue-missing-data-after-import","title":"Issue: Missing Data After Import","text":"

Symptoms: User count, campaign count, or location count lower than V1.

Causes: - Incomplete V1 export (pagination issues) - Transformation script errors (check logs) - Unique constraint violations (duplicates skipped)

Solutions:

# Compare record counts\ndocker compose exec api node scripts/compare-counts.js\n\n# Re-run import for specific table\ndocker compose exec api node scripts/import-data.js --table=users\n\n# Check import logs for errors\ndocker compose logs api | grep ERROR\n

"},{"location":"v2/migration/#issue-geocoding-data-lost","title":"Issue: Geocoding Data Lost","text":"

Symptoms: Locations missing latitude/longitude coordinates.

Causes: - V1 geocoding provider different from V2 - Coordinates not exported from V1 - Transformation script didn't map geocoding fields

Solutions:

# Bulk re-geocode all locations\ncurl -X POST http://localhost:4000/api/map/locations/bulk-geocode \\\n  -H \"Authorization: Bearer $ADMIN_TOKEN\"\n\n# Check geocoding provider configuration\ndocker compose exec api node scripts/test-geocoding.js\n

"},{"location":"v2/migration/#issue-campaign-emails-not-sending","title":"Issue: Campaign Emails Not Sending","text":"

Symptoms: BullMQ queue shows \"failed\" jobs.

Causes: - SMTP configuration incorrect - EMAIL_TEST_MODE still enabled (sends to MailHog) - Nodemailer authentication failure

Solutions:

# Check SMTP configuration\ndocker compose exec api node scripts/test-smtp.js\n\n# View failed job details\n# Visit http://localhost:4000/api/influence/email-queue/stats\n\n# Retry failed jobs\ncurl -X POST http://localhost:4000/api/influence/email-queue/retry-failed \\\n  -H \"Authorization: Bearer $ADMIN_TOKEN\"\n

"},{"location":"v2/migration/#issue-high-memory-usage-after-migration","title":"Issue: High Memory Usage After Migration","text":"

Symptoms: V2 services consuming > 4GB RAM, slow response times.

Causes: - Prisma connection pool too large - Redis cache not evicting old entries - Large JSON fields in database (campaign data, page blocks)

Solutions:

# Reduce Prisma connection pool\n# Edit .env: DATABASE_URL=\"...?connection_limit=5\"\n\n# Clear Redis cache\ndocker compose exec redis redis-cli FLUSHDB\n\n# Optimize database\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"VACUUM ANALYZE;\"\n

"},{"location":"v2/migration/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/migration/#migration-guides","title":"Migration Guides","text":""},{"location":"v2/migration/#v2-setup-guides","title":"V2 Setup Guides","text":""},{"location":"v2/migration/#v2-architecture","title":"V2 Architecture","text":""},{"location":"v2/migration/#post-migration","title":"Post-Migration","text":""},{"location":"v2/migration/#next-steps","title":"Next Steps","text":"

Ready to begin migration?

  1. Review Breaking Changes - Understand all V1\u2192V2 differences
  2. Plan Data Migration - Create migration timeline
  3. Set Up V2 Staging - Test environment
  4. Perform Test Migration - Validate process
  5. Execute Production Migration - Go live

Migration Support

Need help with your migration? Email support@cmlite.org or open a GitHub discussion.

"},{"location":"v2/migration/api-changes/","title":"API Endpoint Changes","text":"

This document provides a comprehensive mapping of V1 API endpoints to their V2 equivalents, including request/response format changes, authentication differences, and code migration examples.

"},{"location":"v2/migration/api-changes/#overview","title":"Overview","text":"

V2 API represents a complete redesign with:

Migration Strategy

Update frontend API calls incrementally, starting with authentication (foundational), then module by module (campaigns, locations, shifts, etc.).

"},{"location":"v2/migration/api-changes/#authentication-changes","title":"Authentication Changes","text":""},{"location":"v2/migration/api-changes/#v1-authentication-session-cookies","title":"V1 Authentication (Session Cookies)","text":"

V1 Login:

// POST /auth/login\nfetch('http://localhost:3333/auth/login', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json' },\n  credentials: 'include', // Send/receive cookies\n  body: JSON.stringify({\n    email: 'admin@example.com',\n    password: 'password123'\n  })\n});\n\n// Response: 302 Redirect to /dashboard\n// Session cookie set automatically\n\n// Subsequent requests\nfetch('http://localhost:3333/campaigns', {\n  credentials: 'include' // Sends session cookie\n});\n

"},{"location":"v2/migration/api-changes/#v2-authentication-jwt-bearer-tokens","title":"V2 Authentication (JWT Bearer Tokens)","text":"

V2 Login:

// POST /api/auth/login\nconst response = await fetch('http://localhost:4000/api/auth/login', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json' },\n  body: JSON.stringify({\n    email: 'admin@example.com',\n    password: 'Admin123!'\n  })\n});\n\nconst data = await response.json();\n\n// Response:\n// {\n//   \"success\": true,\n//   \"data\": {\n//     \"user\": {\n//       \"id\": \"clx1a2b3c4d5e6f7g8h9i\",\n//       \"email\": \"admin@example.com\",\n//       \"name\": \"Admin User\",\n//       \"role\": \"SUPER_ADMIN\"\n//     },\n//     \"accessToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n//     \"refreshToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"\n//   }\n// }\n\n// Store tokens (localStorage, sessionStorage, or memory)\nlocalStorage.setItem('accessToken', data.data.accessToken);\nlocalStorage.setItem('refreshToken', data.data.refreshToken);\n\n// Subsequent requests\nfetch('http://localhost:4000/api/influence/campaigns', {\n  headers: {\n    'Authorization': `Bearer ${localStorage.getItem('accessToken')}`\n  }\n});\n

V2 Token Refresh:

// POST /api/auth/refresh\nconst response = await fetch('http://localhost:4000/api/auth/refresh', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json' },\n  body: JSON.stringify({\n    refreshToken: localStorage.getItem('refreshToken')\n  })\n});\n\nconst data = await response.json();\n\n// Response:\n// {\n//   \"success\": true,\n//   \"data\": {\n//     \"accessToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n//     \"refreshToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\" // New token (rotation)\n//   }\n// }\n\n// Update stored tokens\nlocalStorage.setItem('accessToken', data.data.accessToken);\nlocalStorage.setItem('refreshToken', data.data.refreshToken);\n

"},{"location":"v2/migration/api-changes/#authentication-endpoint-mapping","title":"Authentication Endpoint Mapping","text":"V1 Endpoint V2 Endpoint Method Changes /auth/login /api/auth/login POST Returns JWT tokens instead of setting cookie /auth/logout /api/auth/logout POST Requires refreshToken in body /auth/register /api/auth/register POST Always creates USER role (no role in request) /auth/me /api/auth/me GET Returns 401 if invalid (not 404) - /api/auth/refresh POST New: refresh token rotation"},{"location":"v2/migration/api-changes/#influence-module-api","title":"Influence Module API","text":""},{"location":"v2/migration/api-changes/#campaigns","title":"Campaigns","text":""},{"location":"v2/migration/api-changes/#v1-campaign-endpoints","title":"V1 Campaign Endpoints","text":"
// List campaigns\nGET /campaigns\nQuery: ?page=1\n\n// View campaign\nGET /campaigns/:id\n\n// Create campaign (admin)\nPOST /campaigns/create\nBody: { Title, Description, Slug, IsActive }\n\n// Update campaign (admin)\nPOST /campaigns/:id/edit\nBody: { Title, Description, Slug, IsActive }\n\n// Delete campaign (admin)\nPOST /campaigns/:id/delete\n
"},{"location":"v2/migration/api-changes/#v2-campaign-endpoints","title":"V2 Campaign Endpoints","text":"
// List campaigns\nGET /api/influence/campaigns\nQuery: ?page=1&limit=20&search=query&active=true&highlighted=false\nAuth: Optional (public returns only active campaigns)\n\n// Get campaign by ID\nGET /api/influence/campaigns/:id\nAuth: Required (admin)\n\n// Get campaign by slug (public)\nGET /api/influence/campaigns/public/:slug\nAuth: None\n\n// Create campaign\nPOST /api/influence/campaigns\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nBody: {\n  \"title\": \"Save the Trees\",\n  \"description\": \"Campaign description\",\n  \"slug\": \"save-the-trees\",\n  \"active\": true,\n  \"highlighted\": false,\n  \"targetLevel\": \"federal\",\n  \"targetPosition\": \"MP\",\n  \"responseWallEnabled\": true\n}\n\n// Update campaign\nPUT /api/influence/campaigns/:id\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nBody: { title, description, ... } // Partial update\n\n// Delete campaign\nDELETE /api/influence/campaigns/:id\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Toggle active status\nPATCH /api/influence/campaigns/:id/toggle-active\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Toggle highlighted status\nPATCH /api/influence/campaigns/:id/toggle-highlighted\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n
"},{"location":"v2/migration/api-changes/#campaign-response-format-changes","title":"Campaign Response Format Changes","text":"

V1 Response:

{\n  \"list\": [\n    {\n      \"Id\": 1,\n      \"Title\": \"Save the Trees\",\n      \"Description\": \"Campaign description\",\n      \"Slug\": \"save-the-trees\",\n      \"IsActive\": true,\n      \"Created\": \"2024-01-15T10:30:00Z\"\n    }\n  ],\n  \"pageInfo\": {\n    \"totalRows\": 100,\n    \"page\": 1,\n    \"pageSize\": 20\n  }\n}\n

V2 Response:

{\n  \"success\": true,\n  \"data\": [\n    {\n      \"id\": \"clx1a2b3c4d5e6f7g8h9i\",\n      \"title\": \"Save the Trees\",\n      \"description\": \"Campaign description\",\n      \"slug\": \"save-the-trees\",\n      \"active\": true,\n      \"highlighted\": false,\n      \"targetLevel\": \"federal\",\n      \"targetPosition\": \"MP\",\n      \"responseWallEnabled\": true,\n      \"createdAt\": \"2024-01-15T10:30:00.000Z\",\n      \"updatedAt\": \"2024-01-15T10:30:00.000Z\",\n      \"createdBy\": {\n        \"id\": \"clx1a2b3c4d5e6f7g8h9i\",\n        \"name\": \"Admin User\",\n        \"email\": \"admin@example.com\"\n      }\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 100,\n    \"totalPages\": 5\n  }\n}\n

"},{"location":"v2/migration/api-changes/#representatives","title":"Representatives","text":""},{"location":"v2/migration/api-changes/#v1-representative-endpoints","title":"V1 Representative Endpoints","text":"
// Lookup representatives by postal code\nPOST /representatives/lookup\nBody: { postalCode: \"M5V 1A1\" }\n\n// List cached representatives (admin)\nGET /admin/representatives\n
"},{"location":"v2/migration/api-changes/#v2-representative-endpoints","title":"V2 Representative Endpoints","text":"
// Lookup representatives (public)\nPOST /api/influence/representatives/lookup\nAuth: None\nBody: { \"postalCode\": \"M5V1A1\" }\nResponse: {\n  \"success\": true,\n  \"data\": [\n    {\n      \"name\": \"John Doe\",\n      \"email\": \"john.doe@parl.gc.ca\",\n      \"district\": \"Toronto Centre\",\n      \"party\": \"Liberal\",\n      \"level\": \"federal\",\n      \"photoUrl\": \"https://...\"\n    }\n  ]\n}\n\n// List cached representatives (admin)\nGET /api/influence/representatives\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nQuery: ?page=1&limit=20&level=federal&party=Liberal&search=John\n\n// Get representative stats (admin)\nGET /api/influence/representatives/stats\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nResponse: {\n  \"success\": true,\n  \"data\": {\n    \"total\": 338,\n    \"byLevel\": { \"federal\": 338, \"provincial\": 124 },\n    \"byParty\": { \"Liberal\": 159, \"Conservative\": 119, \"NDP\": 25 }\n  }\n}\n\n// Get representative by ID (admin)\nGET /api/influence/representatives/:id\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Delete representative (admin)\nDELETE /api/influence/representatives/:id\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Health check\nGET /api/influence/representatives/health\nAuth: None\n
"},{"location":"v2/migration/api-changes/#campaign-emails","title":"Campaign Emails","text":""},{"location":"v2/migration/api-changes/#v1-email-endpoints","title":"V1 Email Endpoints","text":"
// Send campaign email\nPOST /campaigns/:id/send-email\nBody: { senderName, senderEmail, postalCode }\n
"},{"location":"v2/migration/api-changes/#v2-email-endpoints","title":"V2 Email Endpoints","text":"
// Send campaign email (public)\nPOST /api/influence/campaign-emails/send-email\nAuth: None\nRate Limit: 30 requests/hour per IP\nBody: {\n  \"campaignId\": \"clx1a2b3c4d5e6f7g8h9i\",\n  \"postalCode\": \"M5V1A1\",\n  \"senderName\": \"Jane Doe\",\n  \"senderEmail\": \"jane@example.com\",\n  \"customMessage\": \"Optional custom message\"\n}\n\n// Track mailto clicks (public)\nGET /api/influence/campaign-emails/track-mailto/:emailId\nAuth: None\n\n// List campaign emails (admin)\nGET /api/influence/campaign-emails\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nQuery: ?campaignId=xxx&page=1&limit=20&sortBy=createdAt&sortOrder=desc\n\n// Get campaign email stats (admin)\nGET /api/influence/campaign-emails/stats/:campaignId\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nResponse: {\n  \"success\": true,\n  \"data\": {\n    \"totalEmails\": 1234,\n    \"queuedEmails\": 5,\n    \"sentEmails\": 1200,\n    \"failedEmails\": 29,\n    \"mailtoClicks\": 340\n  }\n}\n
"},{"location":"v2/migration/api-changes/#email-queue","title":"Email Queue","text":""},{"location":"v2/migration/api-changes/#v2-email-queue-endpoints-new","title":"V2 Email Queue Endpoints (New)","text":"
// Get queue stats (admin)\nGET /api/influence/email-queue/stats\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nResponse: {\n  \"success\": true,\n  \"data\": {\n    \"waiting\": 10,\n    \"active\": 2,\n    \"completed\": 5000,\n    \"failed\": 15,\n    \"delayed\": 0,\n    \"paused\": false\n  }\n}\n\n// Pause queue (admin)\nPOST /api/influence/email-queue/pause\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Resume queue (admin)\nPOST /api/influence/email-queue/resume\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Clean completed jobs (admin)\nPOST /api/influence/email-queue/clean\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nQuery: ?grace=3600 (seconds)\n\n// Retry failed jobs (admin)\nPOST /api/influence/email-queue/retry-failed\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n
"},{"location":"v2/migration/api-changes/#response-wall","title":"Response Wall","text":""},{"location":"v2/migration/api-changes/#v1-response-endpoints","title":"V1 Response Endpoints","text":"
// Submit response\nPOST /responses/submit\nBody: { campaignId, name, email, message }\n\n// List responses\nGET /responses/:campaignId\n
"},{"location":"v2/migration/api-changes/#v2-response-endpoints","title":"V2 Response Endpoints","text":"
// Submit response (public)\nPOST /api/influence/responses/submit\nAuth: None\nBody: {\n  \"campaignId\": \"clx1a2b3c4d5e6f7g8h9i\",\n  \"name\": \"Jane Doe\",\n  \"email\": \"jane@example.com\",\n  \"message\": \"I support this campaign!\",\n  \"ipAddress\": \"192.168.1.1\" // Auto-captured by server\n}\n// Sends verification email\n\n// Verify response email\nGET /api/influence/responses/verify/:token\nAuth: None\n\n// List responses (public)\nGET /api/influence/responses/campaign/:campaignId\nAuth: None\nQuery: ?page=1&limit=20&sortBy=upvotes&sortOrder=desc\nResponse: Only returns APPROVED responses\n\n// Upvote response (public)\nPOST /api/influence/responses/:id/upvote\nAuth: Optional (tracks by IP + userId if logged in)\nBody: { \"ipAddress\": \"192.168.1.1\" }\n\n// List responses (admin)\nGET /api/influence/responses\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nQuery: ?page=1&limit=20&campaignId=xxx&status=PENDING&sortBy=createdAt&sortOrder=desc\n\n// Get response detail (admin)\nGET /api/influence/responses/:id\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Approve response (admin)\nPATCH /api/influence/responses/:id/approve\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Reject response (admin)\nPATCH /api/influence/responses/:id/reject\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Delete response (admin)\nDELETE /api/influence/responses/:id\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n
"},{"location":"v2/migration/api-changes/#map-module-api","title":"Map Module API","text":""},{"location":"v2/migration/api-changes/#locations","title":"Locations","text":""},{"location":"v2/migration/api-changes/#v1-location-endpoints","title":"V1 Location Endpoints","text":"
// List locations\nGET /locations\nQuery: ?page=1\n\n// Create location (admin)\nPOST /locations/create\nBody: { Address, Latitude, Longitude, SupportLevel, Notes }\n\n// Update location (admin)\nPOST /locations/:id/edit\nBody: { Address, Latitude, Longitude, SupportLevel, Notes }\n\n// Delete location (admin)\nPOST /locations/:id/delete\n
"},{"location":"v2/migration/api-changes/#v2-location-endpoints","title":"V2 Location Endpoints","text":"
// List locations (admin)\nGET /api/map/locations\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nQuery: ?page=1&limit=20&search=query&supportLevel=SUPPORT&cutId=xxx&geocoded=true\n\n// List locations (public map)\nGET /api/map/locations/public\nAuth: None\nQuery: ?bounds=minLat,minLng,maxLat,maxLng (returns only geocoded locations)\n\n// Get location by ID (admin)\nGET /api/map/locations/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n\n// Create location (admin)\nPOST /api/map/locations\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: {\n  \"address\": \"123 Main St\",\n  \"city\": \"Toronto\",\n  \"province\": \"ON\",\n  \"postalCode\": \"M5V1A1\",\n  \"country\": \"Canada\",\n  \"latitude\": 43.6532,\n  \"longitude\": -79.3832,\n  \"supportLevel\": \"SUPPORT\",\n  \"notes\": \"Spoke with resident\",\n  \"contactName\": \"John Doe\",\n  \"contactPhone\": \"416-555-1234\",\n  \"contactEmail\": \"john@example.com\",\n  \"cutId\": \"clx1a2b3c4d5e6f7g8h9i\"\n}\n\n// Update location (admin)\nPUT /api/map/locations/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: { address, city, ... } // Partial update\n\n// Delete location (admin)\nDELETE /api/map/locations/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n\n// Bulk delete locations (admin)\nPOST /api/map/locations/bulk-delete\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: { \"ids\": [\"id1\", \"id2\", \"id3\"] }\n\n// Export locations CSV (admin)\nGET /api/map/locations/export\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nQuery: ?supportLevel=SUPPORT&cutId=xxx\n\n// Import locations CSV (admin)\nPOST /api/map/locations/import\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nContent-Type: multipart/form-data\nBody: FormData with CSV file\n\n// Geocode location (admin)\nPOST /api/map/locations/:id/geocode\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nQuery: ?provider=nominatim (optional)\n\n// Bulk geocode (admin)\nPOST /api/map/locations/bulk-geocode\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nQuery: ?limit=100&provider=nominatim\n\n// Reverse geocode (admin)\nPOST /api/map/locations/reverse-geocode\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: { \"latitude\": 43.6532, \"longitude\": -79.3832 }\n\n// Get location stats (admin)\nGET /api/map/locations/stats\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nResponse: {\n  \"success\": true,\n  \"data\": {\n    \"total\": 10000,\n    \"geocoded\": 9500,\n    \"notGeocoded\": 500,\n    \"bySupportLevel\": {\n      \"STRONG_SUPPORT\": 1200,\n      \"SUPPORT\": 3400,\n      \"UNDECIDED\": 2100,\n      \"OPPOSED\": 1800,\n      \"STRONG_OPPOSED\": 800,\n      \"UNKNOWN\": 700\n    }\n  }\n}\n\n// NAR Import (admin, new in V2)\nGET /api/map/locations/nar/datasets\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nResponse: List of available NAR datasets (provinces)\n\nPOST /api/map/locations/nar/import\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: {\n  \"province\": \"24\",\n  \"cityFilter\": \"Toronto\",\n  \"postalCodeFilter\": \"M5V\",\n  \"cutId\": \"clx1a2b3c4d5e6f7g8h9i\",\n  \"residentialOnly\": true\n}\n
"},{"location":"v2/migration/api-changes/#cuts-territories","title":"Cuts (Territories)","text":""},{"location":"v2/migration/api-changes/#v1-cut-endpoints","title":"V1 Cut Endpoints","text":"
// List cuts (admin)\nGET /admin/cuts\n\n// Create cut (admin)\nPOST /admin/cuts/create\nBody: { Name, GeoJSON }\n
"},{"location":"v2/migration/api-changes/#v2-cut-endpoints","title":"V2 Cut Endpoints","text":"
// List cuts (admin)\nGET /api/map/cuts\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nQuery: ?page=1&limit=20&search=query\n\n// List cuts (public map)\nGET /api/map/cuts/public\nAuth: None\nResponse: Only returns active cuts with GeoJSON\n\n// Get cut by ID (admin)\nGET /api/map/cuts/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n\n// Create cut (admin)\nPOST /api/map/cuts\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: {\n  \"name\": \"Downtown Toronto\",\n  \"description\": \"Downtown canvassing area\",\n  \"color\": \"#FF5733\",\n  \"coordinates\": [[[-79.4, 43.6], [-79.3, 43.6], ...]]\n}\n\n// Update cut (admin)\nPUT /api/map/cuts/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: { name, description, color, coordinates }\n\n// Delete cut (admin)\nDELETE /api/map/cuts/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n\n// Get locations in cut (admin)\nGET /api/map/cuts/:id/locations\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nQuery: ?page=1&limit=20\n
"},{"location":"v2/migration/api-changes/#shifts","title":"Shifts","text":""},{"location":"v2/migration/api-changes/#v1-shift-endpoints","title":"V1 Shift Endpoints","text":"
// List shifts\nGET /shifts\n\n// Create shift (admin)\nPOST /shifts/create\nBody: { Name, StartTime, EndTime, Location, Capacity }\n\n// Signup for shift\nPOST /shifts/:id/signup\nBody: { name, email, phone }\n
"},{"location":"v2/migration/api-changes/#v2-shift-endpoints","title":"V2 Shift Endpoints","text":"
// List shifts (public)\nGET /api/map/shifts/public\nAuth: None\nQuery: ?upcoming=true&startDate=2024-01-01&endDate=2024-12-31\nResponse: Only returns future shifts with available capacity\n\n// List shifts (admin)\nGET /api/map/shifts\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nQuery: ?page=1&limit=20&startDate=2024-01-01&endDate=2024-12-31&cutId=xxx\n\n// Get shift by ID (admin)\nGET /api/map/shifts/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n\n// Create shift (admin)\nPOST /api/map/shifts\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: {\n  \"name\": \"Downtown Canvassing\",\n  \"description\": \"Canvassing shift for downtown area\",\n  \"startTime\": \"2024-02-15T09:00:00Z\",\n  \"endTime\": \"2024-02-15T12:00:00Z\",\n  \"location\": \"Community Center, 123 Main St\",\n  \"capacity\": 20,\n  \"requirements\": \"Comfortable shoes, water bottle\",\n  \"cutId\": \"clx1a2b3c4d5e6f7g8h9i\"\n}\n\n// Update shift (admin)\nPUT /api/map/shifts/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: { name, startTime, ... }\n\n// Delete shift (admin)\nDELETE /api/map/shifts/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n\n// Signup for shift (public)\nPOST /api/map/shifts/:id/signup\nAuth: None\nBody: {\n  \"name\": \"Jane Doe\",\n  \"email\": \"jane@example.com\",\n  \"phone\": \"416-555-1234\",\n  \"notes\": \"First time volunteering\"\n}\n// Creates TEMP user if email doesn't exist, sends confirmation email\n\n// Cancel signup (public)\nDELETE /api/map/shifts/:shiftId/signups/:userId\nAuth: Optional (user can cancel own signup)\n\n// List signups for shift (admin)\nGET /api/map/shifts/:id/signups\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n\n// Update signup status (admin)\nPATCH /api/map/shifts/:shiftId/signups/:userId\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: { \"status\": \"COMPLETED\" }\n\n// Email all shift signups (admin)\nPOST /api/map/shifts/:id/email-signups\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: {\n  \"subject\": \"Shift Reminder\",\n  \"message\": \"Don't forget about tomorrow's shift!\"\n}\n\n// Get shift stats (admin)\nGET /api/map/shifts/stats\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nResponse: {\n  \"success\": true,\n  \"data\": {\n    \"totalShifts\": 50,\n    \"upcomingShifts\": 12,\n    \"totalSignups\": 234,\n    \"signupsByStatus\": {\n      \"CONFIRMED\": 200,\n      \"COMPLETED\": 30,\n      \"CANCELLED\": 4\n    }\n  }\n}\n
"},{"location":"v2/migration/api-changes/#canvassing-new-in-v2","title":"Canvassing (New in V2)","text":"
// Start canvass session (volunteer)\nPOST /api/map/canvass/sessions/start\nAuth: Required (any authenticated user)\nBody: {\n  \"shiftId\": \"clx1a2b3c4d5e6f7g8h9i\",\n  \"cutId\": \"clx1a2b3c4d5e6f7g8h9i\"\n}\n\n// End canvass session (volunteer)\nPOST /api/map/canvass/sessions/end\nAuth: Required (any authenticated user)\nBody: { \"sessionId\": \"clx1a2b3c4d5e6f7g8h9i\" }\n\n// Get walking route (volunteer)\nGET /api/map/canvass/routes/:cutId\nAuth: Required (any authenticated user)\nResponse: Optimized walking route (nearest-neighbor algorithm)\n\n// Record visit (volunteer)\nPOST /api/map/canvass/visits\nAuth: Required (any authenticated user)\nRate Limit: 30 requests/minute\nBody: {\n  \"sessionId\": \"clx1a2b3c4d5e6f7g8h9i\",\n  \"locationId\": \"clx1a2b3c4d5e6f7g8h9i\",\n  \"outcome\": \"CONTACT_MADE\",\n  \"supportLevel\": \"SUPPORT\",\n  \"notes\": \"Very interested in campaign\"\n}\n\n// Get canvass dashboard stats (admin)\nGET /api/map/canvass/dashboard/stats\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nResponse: {\n  \"success\": true,\n  \"data\": {\n    \"activeSessions\": 5,\n    \"totalVisitsToday\": 234,\n    \"totalVisitsWeek\": 1420,\n    \"avgVisitsPerSession\": 47\n  }\n}\n\n// Get activity feed (admin)\nGET /api/map/canvass/dashboard/activity\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nQuery: ?limit=50\n\n// Get cut progress (admin)\nGET /api/map/canvass/dashboard/cut-progress\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n\n// Get leaderboard (admin)\nGET /api/map/canvass/dashboard/leaderboard\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nQuery: ?period=week&limit=10\n
"},{"location":"v2/migration/api-changes/#gps-tracking-new-in-v2","title":"GPS Tracking (New in V2)","text":"
// Start tracking session (volunteer)\nPOST /api/map/tracking/sessions/start\nAuth: Required (any authenticated user)\nBody: { \"sessionId\": \"clx1a2b3c4d5e6f7g8h9i\" }\n\n// Record GPS point (volunteer)\nPOST /api/map/tracking/points\nAuth: Required (any authenticated user)\nBody: {\n  \"sessionId\": \"clx1a2b3c4d5e6f7g8h9i\",\n  \"latitude\": 43.6532,\n  \"longitude\": -79.3832,\n  \"accuracy\": 10.5,\n  \"altitude\": 120.3,\n  \"speed\": 1.2\n}\n\n// End tracking session (volunteer)\nPOST /api/map/tracking/sessions/end\nAuth: Required (any authenticated user)\nBody: { \"sessionId\": \"clx1a2b3c4d5e6f7g8h9i\" }\n\n// Get tracking session (admin)\nGET /api/map/tracking/sessions/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n\n// Get tracking points (admin)\nGET /api/map/tracking/sessions/:id/points\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n
"},{"location":"v2/migration/api-changes/#landing-pages-email-templates-new-in-v2","title":"Landing Pages & Email Templates (New in V2)","text":""},{"location":"v2/migration/api-changes/#landing-pages","title":"Landing Pages","text":"
// List landing pages (admin)\nGET /api/pages/admin\nAuth: Required (SUPER_ADMIN)\nQuery: ?page=1&limit=20&search=query\n\n// Get page by ID (admin)\nGET /api/pages/admin/:id\nAuth: Required (SUPER_ADMIN)\n\n// Create page (admin)\nPOST /api/pages/admin\nAuth: Required (SUPER_ADMIN)\nBody: {\n  \"title\": \"About Us\",\n  \"slug\": \"about\",\n  \"content\": \"<html>...</html>\",\n  \"published\": true\n}\n\n// Update page (admin)\nPUT /api/pages/admin/:id\nAuth: Required (SUPER_ADMIN)\nBody: { title, slug, content, published }\n\n// Delete page (admin)\nDELETE /api/pages/admin/:id\nAuth: Required (SUPER_ADMIN)\n\n// Export page to MkDocs (admin)\nPOST /api/pages/admin/:id/export\nAuth: Required (SUPER_ADMIN)\nQuery: ?format=themed&filename=about.html\n\n// Get page by slug (public)\nGET /api/pages/public/:slug\nAuth: None\nResponse: Rendered HTML page\n
"},{"location":"v2/migration/api-changes/#email-templates","title":"Email Templates","text":"
// List templates (admin)\nGET /api/email-templates\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nQuery: ?page=1&limit=20&category=campaign&published=true\n\n// Get template by ID (admin)\nGET /api/email-templates/:id\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Create template (admin)\nPOST /api/email-templates\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nBody: {\n  \"name\": \"Campaign Launch\",\n  \"category\": \"campaign\",\n  \"subject\": \"New Campaign: {{campaignTitle}}\",\n  \"htmlBody\": \"<html>...</html>\",\n  \"textBody\": \"Plain text version\",\n  \"published\": true\n}\n\n// Update template (admin)\nPUT /api/email-templates/:id\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nBody: { name, subject, htmlBody, ... }\n\n// Publish template version (admin)\nPOST /api/email-templates/:id/publish\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Send test email (admin)\nPOST /api/email-templates/:id/test\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nBody: {\n  \"toEmail\": \"test@example.com\",\n  \"variables\": {\n    \"campaignTitle\": \"Save the Trees\",\n    \"userName\": \"Test User\"\n  }\n}\n
"},{"location":"v2/migration/api-changes/#response-format-standards","title":"Response Format Standards","text":""},{"location":"v2/migration/api-changes/#success-response","title":"Success Response","text":"
{\n  \"success\": true,\n  \"data\": { /* response data */ }\n}\n
"},{"location":"v2/migration/api-changes/#paginated-response","title":"Paginated Response","text":"
{\n  \"success\": true,\n  \"data\": [ /* items */ ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 100,\n    \"totalPages\": 5\n  }\n}\n
"},{"location":"v2/migration/api-changes/#error-response","title":"Error Response","text":"
{\n  \"success\": false,\n  \"error\": {\n    \"code\": \"VALIDATION_ERROR\",\n    \"message\": \"Validation failed\",\n    \"details\": [\n      {\n        \"path\": [\"email\"],\n        \"message\": \"Invalid email format\"\n      }\n    ]\n  }\n}\n
"},{"location":"v2/migration/api-changes/#http-status-codes","title":"HTTP Status Codes","text":"Code V1 Usage V2 Usage 200 Success (all responses) Success (GET, PUT, PATCH) 201 - Created (POST) 204 - No Content (DELETE) 400 Validation error Bad Request (validation error) 401 Not logged in Unauthorized (invalid token) 403 - Forbidden (insufficient permissions) 404 Not found Not Found 409 - Conflict (duplicate resource) 422 - Unprocessable Entity (business logic error) 429 - Too Many Requests (rate limit) 500 Server error Internal Server Error"},{"location":"v2/migration/api-changes/#migration-examples","title":"Migration Examples","text":""},{"location":"v2/migration/api-changes/#example-1-campaign-list-page","title":"Example 1: Campaign List Page","text":"

V1 Code:

// Fetch campaigns\nfetch('/campaigns?page=1', {\n  credentials: 'include'\n})\n  .then(res => res.json())\n  .then(data => {\n    displayCampaigns(data.list);\n    displayPagination(data.pageInfo);\n  });\n

V2 Code:

// Fetch campaigns\nconst token = localStorage.getItem('accessToken');\n\nfetch('/api/influence/campaigns?page=1&limit=20', {\n  headers: {\n    'Authorization': `Bearer ${token}`\n  }\n})\n  .then(res => res.json())\n  .then(response => {\n    if (response.success) {\n      displayCampaigns(response.data);\n      displayPagination(response.pagination);\n    } else {\n      handleError(response.error);\n    }\n  });\n

"},{"location":"v2/migration/api-changes/#example-2-location-creation","title":"Example 2: Location Creation","text":"

V1 Code:

// Create location\nfetch('/locations/create', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json' },\n  credentials: 'include',\n  body: JSON.stringify({\n    Address: '123 Main St, Toronto, ON M5V 1A1',\n    Latitude: 43.6532,\n    Longitude: -79.3832,\n    SupportLevel: 'support',\n    Notes: 'Spoke with resident'\n  })\n});\n

V2 Code:

// Create location\nconst token = localStorage.getItem('accessToken');\n\nfetch('/api/map/locations', {\n  method: 'POST',\n  headers: {\n    'Content-Type': 'application/json',\n    'Authorization': `Bearer ${token}`\n  },\n  body: JSON.stringify({\n    address: '123 Main St',\n    city: 'Toronto',\n    province: 'ON',\n    postalCode: 'M5V1A1',\n    country: 'Canada',\n    latitude: 43.6532,\n    longitude: -79.3832,\n    supportLevel: 'SUPPORT',\n    notes: 'Spoke with resident'\n  })\n})\n  .then(res => res.json())\n  .then(response => {\n    if (response.success) {\n      console.log('Created location:', response.data);\n    } else {\n      handleError(response.error);\n    }\n  });\n

"},{"location":"v2/migration/api-changes/#rate-limiting","title":"Rate Limiting","text":"

V2 adds rate limiting to prevent abuse:

Endpoint Limit Window /api/auth/login 10 requests 1 minute /api/auth/register 10 requests 1 minute /api/influence/campaign-emails/send-email 30 requests 1 hour /api/map/canvass/visits 30 requests 1 minute

Rate Limit Headers (V2 only):

X-RateLimit-Limit: 10\nX-RateLimit-Remaining: 8\nX-RateLimit-Reset: 1707835200\n

"},{"location":"v2/migration/api-changes/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/migration/api-changes/#next-steps","title":"Next Steps","text":"
  1. Review endpoint mappings for your application's usage
  2. Update API client to use JWT authentication
  3. Migrate endpoints incrementally (auth first, then modules)
  4. Test error handling with new response format
  5. Implement rate limit handling (exponential backoff)

API Testing

Use tools like Postman or Thunder Client to test V2 endpoints before frontend migration. Import the V2 API collection from /docs/postman-collection.json (if available).

"},{"location":"v2/migration/breaking-changes/","title":"Breaking Changes: V1 to V2","text":"

This document comprehensively details all breaking changes between Changemaker Lite V1 and V2. Review this carefully before migration to understand required code changes, configuration updates, and data transformations.

"},{"location":"v2/migration/breaking-changes/#overview","title":"Overview","text":"

V2 is a clean-room rebuild with fundamental architectural changes. Almost every aspect of the platform has changed, requiring careful planning for migration.

Not a Drop-In Replacement

V2 cannot be deployed alongside V1 without migration. Database schemas, APIs, and authentication are completely incompatible.

"},{"location":"v2/migration/breaking-changes/#major-architectural-changes","title":"Major Architectural Changes","text":""},{"location":"v2/migration/breaking-changes/#1-application-structure","title":"1. Application Structure","text":"

V1 Architecture:

changemaker.lite/\n\u251c\u2500\u2500 influence/          # Separate Express app (port 3333)\n\u2502   \u251c\u2500\u2500 app.js\n\u2502   \u251c\u2500\u2500 routes/\n\u2502   \u2514\u2500\u2500 views/ (EJS)\n\u251c\u2500\u2500 map/                # Separate Express app (port 3000)\n\u2502   \u251c\u2500\u2500 app.js\n\u2502   \u251c\u2500\u2500 routes/\n\u2502   \u2514\u2500\u2500 views/ (EJS)\n\u2514\u2500\u2500 docker-compose.yml\n

V2 Architecture:

changemaker.lite/\n\u251c\u2500\u2500 api/                # Unified Express + Fastify (ports 4000, 4100)\n\u2502   \u251c\u2500\u2500 src/server.ts          # Express main API\n\u2502   \u251c\u2500\u2500 src/media-server.ts    # Fastify media API\n\u2502   \u2514\u2500\u2500 prisma/schema.prisma\n\u251c\u2500\u2500 admin/              # React SPA (port 3000)\n\u2502   \u2514\u2500\u2500 src/\n\u2514\u2500\u2500 docker-compose.yml\n

Impact: V1 had two separate codebases with duplicated auth, middleware, and configuration. V2 consolidates everything into a single unified API.

"},{"location":"v2/migration/breaking-changes/#2-data-layer-transformation","title":"2. Data Layer Transformation","text":"Aspect V1 V2 ORM None (direct NocoDB REST API) Prisma ORM + Drizzle (media) Database NocoDB internal PostgreSQL PostgreSQL 16 direct access Migrations NocoDB auto-migrations Prisma migrate Validation Manual (express-validator) Zod schemas Queries HTTP requests to NocoDB prisma.model.findMany()

V1 Example (NocoDB REST API):

// influence/routes/campaigns.js\nconst campaigns = await axios.get('http://nocodb:8080/api/v1/db/data/v1/campaigns', {\n  headers: { 'xc-token': process.env.NOCODB_API_TOKEN }\n});\n

V2 Example (Prisma ORM):

// api/src/modules/influence/campaigns/campaigns.service.ts\nconst campaigns = await prisma.campaign.findMany({\n  where: { active: true },\n  include: { createdBy: true }\n});\n

Impact: All database queries must be rewritten from HTTP requests to Prisma queries. No migration script can automate this.

"},{"location":"v2/migration/breaking-changes/#3-authentication-system","title":"3. Authentication System","text":""},{"location":"v2/migration/breaking-changes/#session-based-v1-jwt-v2","title":"Session-Based (V1) \u2192 JWT (V2)","text":"

V1 Authentication:

// Session cookies + express-session + Redis store\napp.use(session({\n  store: redisStore,\n  secret: process.env.SESSION_SECRET,\n  resave: false,\n  saveUninitialized: false,\n  cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24 hours\n}));\n\n// Login sets session\nreq.session.userId = user.id;\nreq.session.role = user.role;\n

V2 Authentication:

// JWT access + refresh tokens\nconst accessToken = jwt.sign(\n  { id: user.id, email: user.email, role: user.role },\n  env.JWT_ACCESS_SECRET,\n  { expiresIn: '15m' }\n);\n\nconst refreshToken = jwt.sign(\n  { id: user.id },\n  env.JWT_REFRESH_SECRET,\n  { expiresIn: '7d' }\n);\n\n// Store refresh token in database (rotation on use)\nawait prisma.refreshToken.create({\n  data: { token: refreshToken, userId: user.id }\n});\n

Impact: - V1 sessions do not migrate. All users must re-login after V2 deployment. - Frontend must be rewritten to store JWT tokens (localStorage/sessionStorage). - API requests must include Authorization: Bearer <token> header instead of cookies.

"},{"location":"v2/migration/breaking-changes/#password-hashing","title":"Password Hashing","text":"

Compatibility: Both V1 and V2 use bcrypt, so password hashes can migrate directly.

// V1 (influence/routes/auth.js)\nconst hashedPassword = await bcrypt.hash(password, 10);\n\n// V2 (api/src/modules/auth/auth.service.ts)\nconst hashedPassword = await bcrypt.hash(password, 10);\n\n// Comparison works\nawait bcrypt.compare(inputPassword, migratedHash); // \u2705 Works\n

Password Migration Safe

V1 bcrypt hashes can be copied directly to V2 User.password field. Users can login with existing passwords.

"},{"location":"v2/migration/breaking-changes/#4-user-model-changes","title":"4. User Model Changes","text":""},{"location":"v2/migration/breaking-changes/#v1-user-models-separate-tables","title":"V1 User Models (Separate Tables)","text":"

Influence Users (influence_users table):

{\n  \"id\": 1,\n  \"email\": \"admin@example.com\",\n  \"password\": \"$2b$10...\",\n  \"role\": \"admin\"\n}\n

Login Users (login table):

{\n  \"id\": 1,\n  \"email\": \"admin@example.com\",\n  \"password\": \"$2b$10...\",\n  \"name\": \"Admin User\"\n}\n

Problem: V1 had two separate user tables (one per app) with potential email duplicates.

"},{"location":"v2/migration/breaking-changes/#v2-user-model-unified","title":"V2 User Model (Unified)","text":"
model User {\n  id            String          @id @default(cuid())\n  email         String          @unique  // Enforced unique\n  password      String\n  name          String?\n  phone         String?\n  role          UserRole        @default(USER)\n  status        UserStatus      @default(ACTIVE)\n  createdVia    UserCreatedVia  @default(STANDARD)\n  expiresAt     DateTime?\n  emailVerified Boolean         @default(false)\n  createdAt     DateTime        @default(now())\n  updatedAt     DateTime        @updatedAt\n}\n\nenum UserRole {\n  SUPER_ADMIN\n  INFLUENCE_ADMIN\n  MAP_ADMIN\n  USER\n  TEMP\n}\n

Migration Challenges: 1. Email deduplication: Merge influence_users + login where email matches 2. Role mapping: V1 \"admin\" \u2192 V2 SUPER_ADMIN, V1 \"user\" \u2192 V2 USER 3. Missing fields: V2 adds phone, status, createdVia, emailVerified 4. ID format: V1 integer IDs \u2192 V2 CUID strings (breaks foreign keys)

Migration Script (conceptual):

// Merge V1 users into V2\nconst v1InfluenceUsers = await fetchFromNocoDB('influence_users');\nconst v1LoginUsers = await fetchFromNocoDB('login');\n\nconst mergedUsers = mergeByEmail(v1InfluenceUsers, v1LoginUsers);\n\nfor (const user of mergedUsers) {\n  await prisma.user.create({\n    data: {\n      email: user.email,\n      password: user.password, // bcrypt hash migrates directly\n      name: user.name || null,\n      role: mapRole(user.role), // 'admin' \u2192 'SUPER_ADMIN'\n      createdAt: user.created_at || new Date()\n    }\n  });\n}\n

"},{"location":"v2/migration/breaking-changes/#5-frontend-stack","title":"5. Frontend Stack","text":"Aspect V1 V2 Framework Server-rendered EJS React 19 SPA Build Tool None (direct EJS rendering) Vite 5 UI Library Bootstrap 4 Ant Design 5 State Management Server session Zustand stores Routing Express routes (server-side) React Router (client-side) Styling CSS + Bootstrap CSS Modules + Ant Design tokens

V1 View (EJS template):

<!-- influence/views/campaigns.ejs -->\n<% campaigns.forEach(campaign => { %>\n  <div class=\"card\">\n    <h3><%= campaign.title %></h3>\n    <p><%= campaign.description %></p>\n  </div>\n<% }) %>\n

V2 Component (React + TypeScript):

// admin/src/pages/CampaignsPage.tsx\nconst CampaignsPage = () => {\n  const [campaigns, setCampaigns] = useState<Campaign[]>([]);\n\n  useEffect(() => {\n    api.get('/api/influence/campaigns').then(res => {\n      setCampaigns(res.data.data);\n    });\n  }, []);\n\n  return (\n    <Table dataSource={campaigns} columns={columns} />\n  );\n};\n

Impact: - V1 views cannot be reused. All UI must be rewritten in React. - Client-side routing requires API design (RESTful endpoints). - State management shifts from server (session) to client (Zustand).

"},{"location":"v2/migration/breaking-changes/#api-changes","title":"API Changes","text":""},{"location":"v2/migration/breaking-changes/#endpoint-url-structure","title":"Endpoint URL Structure","text":"

V1 Endpoints:

# Influence app (port 3333)\nGET  /campaigns\nPOST /campaigns/create\nGET  /campaigns/:id/edit\nPOST /representatives/lookup\n\n# Map app (port 3000)\nGET  /locations\nPOST /locations/create\nGET  /shifts\n

V2 Endpoints:

# Unified API (port 4000)\nGET    /api/influence/campaigns\nPOST   /api/influence/campaigns\nGET    /api/influence/campaigns/:id\nPUT    /api/influence/campaigns/:id\nDELETE /api/influence/campaigns/:id\nPOST   /api/influence/representatives/lookup\n\nGET    /api/map/locations\nPOST   /api/map/locations\nGET    /api/map/locations/:id\nGET    /api/map/shifts\n

Changes: 1. All endpoints prefixed with /api/ 2. RESTful conventions (GET/POST/PUT/DELETE instead of /create, /edit) 3. Single port (4000) instead of two apps 4. Namespaced by module (/influence/, /map/)

"},{"location":"v2/migration/breaking-changes/#requestresponse-format","title":"Request/Response Format","text":"

V1 Response (NocoDB-style):

{\n  \"list\": [\n    {\n      \"Id\": 1,\n      \"Title\": \"Save the Trees\",\n      \"Created\": \"2024-01-15T10:30:00Z\"\n    }\n  ],\n  \"pageInfo\": {\n    \"totalRows\": 100,\n    \"page\": 1,\n    \"pageSize\": 20\n  }\n}\n

V2 Response (standardized):

{\n  \"success\": true,\n  \"data\": [\n    {\n      \"id\": \"clx1a2b3c4d5e6f7g8h9i\",\n      \"title\": \"Save the Trees\",\n      \"createdAt\": \"2024-01-15T10:30:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 100,\n    \"totalPages\": 5\n  }\n}\n

Changes: - V2 wraps responses in { success, data, pagination } structure - Field names: camelCase (createdAt) vs mixed case (Created) - IDs: CUID strings vs integers - Timestamps: ISO 8601 with milliseconds

"},{"location":"v2/migration/breaking-changes/#authentication-headers","title":"Authentication Headers","text":"

V1 Requests:

// Session cookie sent automatically\nfetch('/campaigns', {\n  method: 'GET',\n  credentials: 'include' // Sends session cookie\n});\n

V2 Requests:

// JWT Bearer token required\nfetch('/api/influence/campaigns', {\n  method: 'GET',\n  headers: {\n    'Authorization': `Bearer ${accessToken}`\n  }\n});\n

Impact: All API calls must be updated to include Authorization header. No more cookie-based authentication.

"},{"location":"v2/migration/breaking-changes/#validation-errors","title":"Validation Errors","text":"

V1 Validation (express-validator):

{\n  \"errors\": [\n    {\n      \"msg\": \"Invalid email\",\n      \"param\": \"email\",\n      \"location\": \"body\"\n    }\n  ]\n}\n

V2 Validation (Zod):

{\n  \"success\": false,\n  \"error\": {\n    \"code\": \"VALIDATION_ERROR\",\n    \"message\": \"Validation failed\",\n    \"details\": [\n      {\n        \"path\": [\"email\"],\n        \"message\": \"Invalid email\"\n      }\n    ]\n  }\n}\n

"},{"location":"v2/migration/breaking-changes/#database-schema-changes","title":"Database Schema Changes","text":""},{"location":"v2/migration/breaking-changes/#campaign-model","title":"Campaign Model","text":"

V1 NocoDB Table (campaigns):

Columns:\n- Id (integer, auto-increment)\n- Title (string)\n- Description (text)\n- Slug (string)\n- IsActive (boolean)\n- Created (datetime)\n

V2 Prisma Model:

model Campaign {\n  id                   String    @id @default(cuid())\n  title                String\n  description          String?\n  slug                 String    @unique\n  active               Boolean   @default(true)\n  highlighted          Boolean   @default(false)\n  targetLevel          String?\n  targetPosition       String?\n  targetName           String?\n  targetEmail          String?\n  targetPostalCode     String?\n  customSubject        String?\n  customBody           String?\n  responseWallEnabled  Boolean   @default(true)\n  createdAt            DateTime  @default(now())\n  updatedAt            DateTime  @updatedAt\n  createdByUserId      String\n  createdBy            User      @relation(\"CampaignCreator\", fields: [createdByUserId], references: [id])\n\n  emails               CampaignEmail[]\n  responses            RepresentativeResponse[]\n}\n

Changes: 1. New fields: highlighted, targetLevel, responseWallEnabled, createdByUserId 2. Relations: Foreign key to User (V1 had no user relation) 3. Renamed: IsActive \u2192 active, Created \u2192 createdAt 4. Type changes: Description text \u2192 String? (nullable)

"},{"location":"v2/migration/breaking-changes/#location-model","title":"Location Model","text":"

V1 NocoDB Table (locations):

Columns:\n- Id (integer)\n- Address (string)\n- Latitude (float)\n- Longitude (float)\n- SupportLevel (string)\n- Notes (text)\n

V2 Prisma Model:

model Location {\n  id                 String              @id @default(cuid())\n  address            String\n  addressLine2       String?\n  city               String?\n  province           String?\n  postalCode         String?\n  country            String              @default(\"Canada\")\n  latitude           Float?\n  longitude          Float?\n  geocoded           Boolean             @default(false)\n  geocodedAt         DateTime?\n  geocodeProvider    String?\n  geocodeQuality     String?\n  supportLevel       SupportLevel        @default(UNKNOWN)\n  notes              String?\n  contactName        String?\n  contactPhone       String?\n  contactEmail       String?\n  unitNumber         String?\n  buildingName       String?\n  buildingUse        String?\n  federalDistrict    String?\n  cutId              String?\n  createdAt          DateTime            @default(now())\n  updatedAt          DateTime            @updatedAt\n  createdByUserId    String\n  updatedByUserId    String?\n\n  createdBy          User                @relation(\"LocationCreator\", fields: [createdByUserId], references: [id])\n  updatedBy          User?               @relation(\"LocationUpdater\", fields: [updatedByUserId], references: [id])\n  cut                Cut?                @relation(fields: [cutId], references: [id])\n  addresses          Address[]\n  history            LocationHistory[]\n  canvassVisits      CanvassVisit[]\n}\n\nenum SupportLevel {\n  STRONG_SUPPORT\n  SUPPORT\n  UNDECIDED\n  OPPOSED\n  STRONG_OPPOSED\n  UNKNOWN\n  NOT_HOME\n  MOVED\n  DECEASED\n}\n

Changes: 1. Structured address: V1 single Address \u2192 V2 address, city, province, postalCode 2. Geocoding metadata: geocoded, geocodedAt, geocodeProvider, geocodeQuality 3. Contact fields: contactName, contactPhone, contactEmail 4. NAR fields: unitNumber, buildingName, buildingUse, federalDistrict 5. Relations: cutId, createdByUserId, updatedByUserId 6. SupportLevel: V1 string \u2192 V2 enum

"},{"location":"v2/migration/breaking-changes/#shift-model","title":"Shift Model","text":"

V1 NocoDB Table (shifts):

Columns:\n- Id (integer)\n- Name (string)\n- StartTime (datetime)\n- EndTime (datetime)\n- Location (string)\n- Capacity (integer)\n

V2 Prisma Model:

model Shift {\n  id                String         @id @default(cuid())\n  name              String\n  description       String?\n  startTime         DateTime\n  endTime           DateTime\n  location          String?\n  capacity          Int?\n  requirements      String?\n  cutId             String?\n  createdAt         DateTime       @default(now())\n  updatedAt         DateTime       @updatedAt\n\n  cut               Cut?           @relation(fields: [cutId], references: [id])\n  signups           ShiftSignup[]\n}\n\nmodel ShiftSignup {\n  id                String         @id @default(cuid())\n  shiftId           String\n  userId            String\n  status            SignupStatus   @default(CONFIRMED)\n  notes             String?\n  confirmedAt       DateTime?\n  cancelledAt       DateTime?\n  createdAt         DateTime       @default(now())\n\n  shift             Shift          @relation(fields: [shiftId], references: [id], onDelete: Cascade)\n  user              User           @relation(fields: [userId], references: [id])\n\n  @@unique([shiftId, userId])\n}\n\nenum SignupStatus {\n  PENDING\n  CONFIRMED\n  CANCELLED\n  COMPLETED\n  NO_SHOW\n}\n

Changes: 1. Separate signups: V1 embedded \u2192 V2 ShiftSignup relation table 2. New fields: description, requirements, cutId 3. Signup tracking: status, confirmedAt, cancelledAt 4. Unique constraint: One signup per user per shift

"},{"location":"v2/migration/breaking-changes/#configuration-changes","title":"Configuration Changes","text":""},{"location":"v2/migration/breaking-changes/#environment-variables","title":"Environment Variables","text":"

V1 Environment (.env):

# V1 used separate .env files per app\n\n# influence/.env\nPORT=3333\nNOCODB_URL=http://nocodb:8080\nNOCODB_API_TOKEN=xxxxx\nSESSION_SECRET=xxxxx\nREDIS_URL=redis://redis:6379\nSMTP_HOST=smtp.example.com\nSMTP_USER=user@example.com\nSMTP_PASS=password\n\n# map/.env\nPORT=3000\nNOCODB_URL=http://nocodb:8080\nNOCODB_API_TOKEN=xxxxx\nSESSION_SECRET=xxxxx  # Different secret!\nREDIS_URL=redis://redis:6379\n

V2 Environment (.env):

# Single unified .env file\n\n# Database\nDATABASE_URL=postgresql://changemaker:password@v2-postgres:5432/changemaker_v2?schema=public\nV2_POSTGRES_USER=changemaker\nV2_POSTGRES_PASSWORD=strongpassword\nV2_POSTGRES_DB=changemaker_v2\n\n# Redis\nREDIS_URL=redis://:password@redis:6379\nREDIS_PASSWORD=redispassword\n\n# JWT Authentication\nJWT_ACCESS_SECRET=access_secret_32_chars_minimum\nJWT_REFRESH_SECRET=refresh_secret_32_chars_minimum\nENCRYPTION_KEY=encryption_key_32_chars_different_from_jwt\n\n# API\nAPI_PORT=4000\nMEDIA_API_PORT=4100\nNODE_ENV=production\n\n# Email (SMTP)\nSMTP_HOST=smtp.protonmail.com\nSMTP_PORT=587\nSMTP_SECURE=false\nSMTP_USER=your@protonmail.com\nSMTP_PASS=yourapppassword\nSMTP_FROM=noreply@cmlite.org\nEMAIL_TEST_MODE=false\n\n# BullMQ\nBULLMQ_REDIS_URL=redis://:password@redis:6379\n\n# Listmonk (optional)\nLISTMONK_SYNC_ENABLED=true\nLISTMONK_URL=http://listmonk:9001\nLISTMONK_ADMIN_USER=api_user\nLISTMONK_ADMIN_PASSWORD=api_token\n\n# Feature Flags\nENABLE_MEDIA_FEATURES=true\n

Removed V1 Variables: - NOCODB_URL (no longer using NocoDB as data layer) - NOCODB_API_TOKEN - SESSION_SECRET (replaced by JWT secrets)

New V2 Variables: - DATABASE_URL (direct PostgreSQL connection) - JWT_ACCESS_SECRET, JWT_REFRESH_SECRET - ENCRYPTION_KEY (for encrypting sensitive DB fields) - LISTMONK_SYNC_ENABLED (newsletter integration) - ENABLE_MEDIA_FEATURES (media library toggle)

"},{"location":"v2/migration/breaking-changes/#docker-compose-changes","title":"Docker Compose Changes","text":"

V1 Services:

services:\n  influence-app:\n    build: ./influence\n    ports:\n      - \"3333:3333\"\n\n  map-app:\n    build: ./map\n    ports:\n      - \"3000:3000\"\n\n  nocodb:\n    image: nocodb/nocodb:latest\n    ports:\n      - \"8080:8080\"\n\n  redis:\n    image: redis:alpine\n

V2 Services:

services:\n  api:\n    build: ./api\n    ports:\n      - \"4000:4000\"\n    depends_on:\n      - v2-postgres\n      - redis\n\n  media-api:\n    build:\n      context: ./api\n      dockerfile: Dockerfile.media\n    ports:\n      - \"4100:4100\"\n\n  admin:\n    build: ./admin\n    ports:\n      - \"3000:3000\"\n\n  v2-postgres:\n    image: postgres:16-alpine\n    ports:\n      - \"5433:5432\"\n\n  redis:\n    image: redis:7-alpine\n    command: redis-server --requirepass ${REDIS_PASSWORD}\n\n  nginx:\n    image: nginx:alpine\n    ports:\n      - \"80:80\"\n      - \"443:443\"\n

Changes: 1. Removed: influence-app, map-app, nocodb 2. Added: api, media-api, admin, v2-postgres, nginx 3. Port changes: API 3333/3000 \u2192 4000, Admin GUI on 3000 4. Redis: Now requires authentication (--requirepass)

"},{"location":"v2/migration/breaking-changes/#code-migration-examples","title":"Code Migration Examples","text":""},{"location":"v2/migration/breaking-changes/#campaign-list-endpoint","title":"Campaign List Endpoint","text":"

V1 Implementation:

// influence/routes/campaigns.js\nrouter.get('/campaigns', async (req, res) => {\n  try {\n    const response = await axios.get(\n      `${process.env.NOCODB_URL}/api/v1/db/data/v1/campaigns`,\n      {\n        headers: { 'xc-token': process.env.NOCODB_API_TOKEN },\n        params: {\n          where: '(IsActive,eq,true)',\n          sort: '-Created',\n          limit: 20,\n          offset: req.query.page ? (req.query.page - 1) * 20 : 0\n        }\n      }\n    );\n\n    res.render('campaigns', {\n      campaigns: response.data.list,\n      pageInfo: response.data.pageInfo\n    });\n  } catch (error) {\n    console.error(error);\n    res.status(500).send('Error fetching campaigns');\n  }\n});\n

V2 Implementation:

// api/src/modules/influence/campaigns/campaigns.routes.ts\nrouter.get('/', authenticate, async (req: Request, res: Response) => {\n  const query = listCampaignsSchema.parse(req.query);\n  const result = await campaignService.list(query);\n  res.json({ success: true, ...result });\n});\n\n// api/src/modules/influence/campaigns/campaigns.service.ts\nasync list(params: ListCampaignsParams) {\n  const { page = 1, limit = 20, search, active, highlighted } = params;\n\n  const where: Prisma.CampaignWhereInput = {\n    ...(active !== undefined && { active }),\n    ...(highlighted !== undefined && { highlighted }),\n    ...(search && {\n      OR: [\n        { title: { contains: search, mode: 'insensitive' } },\n        { description: { contains: search, mode: 'insensitive' } }\n      ]\n    })\n  };\n\n  const [campaigns, total] = await Promise.all([\n    prisma.campaign.findMany({\n      where,\n      include: { createdBy: { select: { id: true, name: true, email: true } } },\n      orderBy: { createdAt: 'desc' },\n      skip: (page - 1) * limit,\n      take: limit\n    }),\n    prisma.campaign.count({ where })\n  ]);\n\n  return {\n    data: campaigns,\n    pagination: {\n      page,\n      limit,\n      total,\n      totalPages: Math.ceil(total / limit)\n    }\n  };\n}\n

Changes: 1. V1: HTTP request to NocoDB \u2192 V2: Prisma ORM query 2. V1: Query string filtering \u2192 V2: Zod schema validation 3. V1: EJS rendering \u2192 V2: JSON API response 4. V1: Manual pagination \u2192 V2: Standardized pagination object 5. V2: Type safety (TypeScript), includes relations

"},{"location":"v2/migration/breaking-changes/#user-login","title":"User Login","text":"

V1 Implementation:

// influence/routes/auth.js\nrouter.post('/login', async (req, res) => {\n  const { email, password } = req.body;\n\n  const response = await axios.get(\n    `${process.env.NOCODB_URL}/api/v1/db/data/v1/influence_users`,\n    {\n      headers: { 'xc-token': process.env.NOCODB_API_TOKEN },\n      params: { where: `(Email,eq,${email})` }\n    }\n  );\n\n  if (response.data.list.length === 0) {\n    return res.status(401).send('Invalid credentials');\n  }\n\n  const user = response.data.list[0];\n  const validPassword = await bcrypt.compare(password, user.Password);\n\n  if (!validPassword) {\n    return res.status(401).send('Invalid credentials');\n  }\n\n  req.session.userId = user.Id;\n  req.session.role = user.Role;\n\n  res.redirect('/dashboard');\n});\n

V2 Implementation:

// api/src/modules/auth/auth.service.ts\nasync login(email: string, password: string) {\n  const user = await prisma.user.findUnique({ where: { email } });\n\n  if (!user) {\n    // Prevent user enumeration - same error for wrong email or password\n    throw new UnauthorizedError('Invalid credentials');\n  }\n\n  const validPassword = await bcrypt.compare(password, user.password);\n  if (!validPassword) {\n    throw new UnauthorizedError('Invalid credentials');\n  }\n\n  if (user.status !== 'ACTIVE') {\n    throw new UnauthorizedError('Account is not active');\n  }\n\n  // Generate JWT tokens\n  const accessToken = jwt.sign(\n    { id: user.id, email: user.email, role: user.role },\n    env.JWT_ACCESS_SECRET,\n    { expiresIn: '15m' }\n  );\n\n  const refreshToken = jwt.sign(\n    { id: user.id },\n    env.JWT_REFRESH_SECRET,\n    { expiresIn: '7d' }\n  );\n\n  // Store refresh token (with rotation on use)\n  await prisma.refreshToken.create({\n    data: {\n      token: refreshToken,\n      userId: user.id,\n      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)\n    }\n  });\n\n  // Update last login\n  await prisma.user.update({\n    where: { id: user.id },\n    data: { lastLoginAt: new Date() }\n  });\n\n  return {\n    user: { id: user.id, email: user.email, name: user.name, role: user.role },\n    accessToken,\n    refreshToken\n  };\n}\n

Changes: 1. V1: Session storage \u2192 V2: JWT tokens returned to client 2. V1: Redirect to dashboard \u2192 V2: JSON response with tokens 3. V2: User enumeration prevention (same error message) 4. V2: Account status check (ACTIVE, SUSPENDED, etc.) 5. V2: Refresh token storage for rotation 6. V2: Last login tracking

"},{"location":"v2/migration/breaking-changes/#deployment-changes","title":"Deployment Changes","text":""},{"location":"v2/migration/breaking-changes/#port-mapping","title":"Port Mapping","text":"Service V1 Port V2 Port Notes Influence App 3333 - Removed Map App 3000 - Removed Admin GUI - 3000 New React app Express API - 4000 New unified API Fastify Media API - 4100 New media service NocoDB 8080 8091 Now read-only browser PostgreSQL (main) - 5433 New V2 database Listmonk - 9001 New newsletter service Grafana - 3001 New monitoring Prometheus - 9090 New metrics"},{"location":"v2/migration/breaking-changes/#nginx-routing","title":"Nginx Routing","text":"

V1 Nginx (simple proxy):

server {\n  listen 80;\n  server_name cmlite.org;\n\n  location /influence {\n    proxy_pass http://influence-app:3333;\n  }\n\n  location /map {\n    proxy_pass http://map-app:3000;\n  }\n}\n

V2 Nginx (subdomain routing):

# Admin GUI\nserver {\n  listen 80;\n  server_name app.cmlite.org;\n  location / {\n    proxy_pass http://admin:3000;\n  }\n}\n\n# Main API\nserver {\n  listen 80;\n  server_name api.cmlite.org;\n  location / {\n    proxy_pass http://api:4000;\n  }\n}\n\n# Media API\nserver {\n  listen 80;\n  server_name media.cmlite.org;\n  location / {\n    proxy_pass http://media-api:4100;\n  }\n}\n\n# Public site (MkDocs)\nserver {\n  listen 80;\n  server_name cmlite.org;\n  location / {\n    proxy_pass http://mkdocs:4001;\n  }\n}\n

Impact: V2 requires DNS configuration for subdomains (app., api., media., etc.).

"},{"location":"v2/migration/breaking-changes/#feature-changes","title":"Feature Changes","text":""},{"location":"v2/migration/breaking-changes/#features-removed-in-v2","title":"Features Removed in V2","text":"
  1. NocoDB Data Browser (as primary interface)
  2. V2 uses NocoDB only as read-only browser
  3. All CRUD operations via API/Admin GUI

  4. Embedded EJS Views

  5. No server-rendered templates
  6. All UI is React SPA

  7. Session-Based Multi-Tenancy

  8. V1 supported multiple campaigns with session isolation
  9. V2 is single-tenant (one installation per organization)
"},{"location":"v2/migration/breaking-changes/#features-added-in-v2","title":"Features Added in V2","text":"
  1. Landing Page Builder
  2. GrapesJS visual editor
  3. Custom blocks library
  4. MkDocs export (Jinja2 templates)

  5. Email Templates System

  6. Template versioning
  7. Variable substitution
  8. Live preview
  9. HTML + plain text variants

  10. Media Library

  11. Video upload with FFprobe metadata
  12. Public gallery with categories
  13. Reaction system (6 emoji types)
  14. Bulk operations

  15. Volunteer Canvassing

  16. GPS tracking sessions
  17. Walking route algorithm
  18. Visit outcome recording
  19. Admin dashboard with leaderboards

  20. Data Quality Dashboard

  21. Geocoding quality metrics
  22. Provider performance comparison
  23. Bulk re-geocoding tools

  24. Comprehensive Monitoring

  25. Prometheus metrics (12 custom cm_* metrics)
  26. Grafana dashboards (3 pre-configured)
  27. Alertmanager with Gotify integration
  28. Docker healthchecks

  29. NAR 2025 Import

  30. Canadian electoral data import
  31. Server-side streaming (large files)
  32. Location + Address file joining
  33. Province/city/postal filtering

  34. Pangolin Tunnel

  35. Self-hosted tunnel alternative to Cloudflare
  36. Newt container integration
  37. Admin setup wizard
"},{"location":"v2/migration/breaking-changes/#features-changed-in-v2","title":"Features Changed in V2","text":"
  1. Campaign Email Sending
  2. V1: Bull job queue \u2192 V2: BullMQ with monitoring
  3. V1: Single SMTP config \u2192 V2: Test mode + Listmonk integration

  4. Response Wall

  5. V1: Simple submission form \u2192 V2: Moderation + upvoting + verification

  6. Geocoding

  7. V1: Single provider (Nominatim) \u2192 V2: 6 providers with fallback
  8. V2 adds: ArcGIS, Photon, Mapbox, Google, OpenCage

  9. User Roles

  10. V1: admin, user \u2192 V2: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP
  11. V2: Role-based access control (RBAC) middleware
"},{"location":"v2/migration/breaking-changes/#security-changes","title":"Security Changes","text":""},{"location":"v2/migration/breaking-changes/#enhancements-in-v2","title":"Enhancements in V2","text":"
  1. Password Policy
  2. V1: No requirements \u2192 V2: 12+ chars, uppercase, lowercase, digit (Zod schema)

  3. Rate Limiting

  4. V1: None \u2192 V2: Auth endpoints 10/min per IP, canvass visits 30/min

  5. Refresh Token Rotation

  6. V1: Static sessions \u2192 V2: Atomic token rotation (prevents replay attacks)

  7. User Enumeration Prevention

  8. V2: Login returns 401 for both invalid email and password (V1 returned different errors)

  9. Redis Authentication

  10. V1: No password \u2192 V2: Required REDIS_PASSWORD

  11. Encryption Key

  12. V2: Separate ENCRYPTION_KEY for sensitive DB fields (different from JWT secrets)

  13. Input Sanitization

  14. V2: HTML escaping for user content (responses, emails, templates)

  15. Path Traversal Protection

  16. V2: Null byte checks, path normalization, encoded traversal blocking
"},{"location":"v2/migration/breaking-changes/#security-audit","title":"Security Audit","text":"

V2 underwent comprehensive security audit (2025-02-11) addressing 13 findings: - 1 Critical, 6 Important, 3 Medium, 2 Low, 1 Suggestion

See Security Audit Report for details.

"},{"location":"v2/migration/breaking-changes/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/migration/breaking-changes/#v1-performance-characteristics","title":"V1 Performance Characteristics","text":""},{"location":"v2/migration/breaking-changes/#v2-performance-improvements","title":"V2 Performance Improvements","text":"
  1. Direct Database Access
  2. Prisma ORM eliminates REST API overhead
  3. Connection pooling reduces latency

  4. Query Optimization

  5. Prisma includes relations in single query (no N+1)
  6. Indexed foreign keys, unique constraints

  7. Caching Strategy

  8. Redis cache for representatives (60min TTL)
  9. Redis cache for postal codes (persistent)
  10. Prisma query result caching

  11. Dual API Architecture

  12. Media API (Fastify) handles video uploads separately
  13. Prevents main API blocking on large file uploads

  14. Monitoring

  15. Prometheus http_request_duration_seconds histogram
  16. Slow query detection via metrics
  17. Grafana alerting on high latency
"},{"location":"v2/migration/breaking-changes/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/migration/breaking-changes/#next-steps","title":"Next Steps","text":"
  1. Review this breaking changes document thoroughly
  2. Plan data transformation scripts (user merging, ID mapping)
  3. Test authentication migration (password hashes, login flow)
  4. Set up V2 staging environment for testing
  5. Proceed to Data Migration Guide

Migration Complexity

V2 migration is complex due to fundamental architectural changes. Budget 2-4 weeks for planning, scripting, testing, and execution.

"},{"location":"v2/migration/data-migration/","title":"Data Migration Procedures","text":"

This guide provides step-by-step procedures for migrating data from Changemaker Lite V1 to V2, including export scripts, transformation logic, import procedures, and validation steps.

"},{"location":"v2/migration/data-migration/#overview","title":"Overview","text":"

V2 data migration involves:

  1. Export - Extract data from V1 NocoDB tables
  2. Transform - Convert V1 schema to V2 Prisma models
  3. Import - Load transformed data into V2 PostgreSQL
  4. Validate - Verify data integrity and completeness

Production Migration Warning

ALWAYS perform a test migration on a staging environment before production. Data loss is possible if scripts contain errors.

"},{"location":"v2/migration/data-migration/#prerequisites","title":"Prerequisites","text":"

Before beginning data migration:

"},{"location":"v2/migration/data-migration/#data-mapping","title":"Data Mapping","text":""},{"location":"v2/migration/data-migration/#v1-tables-v2-prisma-models","title":"V1 Tables \u2192 V2 Prisma Models","text":"V1 NocoDB Table V2 Prisma Model Notes influence_users User Merge with login table login User Merge with influence_users campaigns Campaign Add createdByUserId relation representatives Representative Direct migration responses RepresentativeResponse Add verification fields response_upvotes ResponseUpvote Add IP dedup field postal_code_cache PostalCodeCache Direct migration locations Location Split address, add geocoding fields shifts Shift Extract signups to ShiftSignup shift_signups ShiftSignup Add status enum cuts Cut Parse GeoJSON coordinates (none) RefreshToken New in V2 (generated on first login) (none) SiteSettings New in V2 (seed with defaults) (none) MapSettings New in V2 (seed with defaults)"},{"location":"v2/migration/data-migration/#field-mapping-tables","title":"Field Mapping Tables","text":""},{"location":"v2/migration/data-migration/#users","title":"Users","text":"V1 Field (influence_users) V1 Field (login) V2 Field Transformation Id Id - Discard (V2 uses CUID) Email Email email Merge by email, enforce unique Password Password password Bcrypt hash (direct copy) - Name name From login.Name - - phone NULL (not in V1) Role - role Map: 'admin'\u2192'SUPER_ADMIN', 'user'\u2192'USER' - - status Default: 'ACTIVE' - - createdVia Default: 'STANDARD' - - expiresAt NULL - - emailVerified Default: false Created Created createdAt ISO 8601 timestamp - - updatedAt Use createdAt or current time

Merge Logic:

// Pseudocode\nconst mergeUsers = (influenceUsers, loginUsers) => {\n  const merged = new Map();\n\n  // Add all login users first (has name field)\n  loginUsers.forEach(user => {\n    merged.set(user.Email.toLowerCase(), {\n      email: user.Email,\n      password: user.Password,\n      name: user.Name,\n      role: 'USER', // Default, may be overridden\n      createdAt: user.Created || new Date()\n    });\n  });\n\n  // Override with influence_users (has role field)\n  influenceUsers.forEach(user => {\n    const existing = merged.get(user.Email.toLowerCase());\n    if (existing) {\n      existing.role = mapRole(user.Role);\n    } else {\n      merged.set(user.Email.toLowerCase(), {\n        email: user.Email,\n        password: user.Password,\n        name: null,\n        role: mapRole(user.Role),\n        createdAt: user.Created || new Date()\n      });\n    }\n  });\n\n  return Array.from(merged.values());\n};\n\nconst mapRole = (v1Role) => {\n  const roleMap = {\n    'admin': 'SUPER_ADMIN',\n    'moderator': 'INFLUENCE_ADMIN',\n    'user': 'USER'\n  };\n  return roleMap[v1Role] || 'USER';\n};\n

"},{"location":"v2/migration/data-migration/#campaigns","title":"Campaigns","text":"V1 Field V2 Field Transformation Id - Discard (use CUID) Title title Direct copy Description description Direct copy Slug slug Direct copy IsActive active Boolean conversion - highlighted Default: false TargetLevel targetLevel Direct copy or NULL TargetPosition targetPosition Direct copy or NULL - targetName NULL (not in V1) - targetEmail NULL - targetPostalCode NULL - customSubject NULL - customBody NULL - responseWallEnabled Default: true Created createdAt ISO 8601 timestamp - updatedAt Use createdAt - createdByUserId Requires user lookup

CreatedBy Mapping:

// V1 campaigns may not have createdBy field\n// Options:\n// 1. Assign all to first SUPER_ADMIN user\n// 2. Use separate mapping table if V1 tracked creators\n// 3. Create placeholder \"System\" user\n\nconst assignCreator = async (campaign) => {\n  // Find first SUPER_ADMIN user\n  const admin = await prisma.user.findFirst({\n    where: { role: 'SUPER_ADMIN' }\n  });\n\n  if (!admin) {\n    throw new Error('No SUPER_ADMIN user found. Create admin user first.');\n  }\n\n  return admin.id;\n};\n

"},{"location":"v2/migration/data-migration/#locations","title":"Locations","text":"V1 Field V2 Field Transformation Id - Discard (use CUID) Address address, city, province, postalCode Parse address string - addressLine2 NULL - country Default: 'Canada' Latitude latitude Float conversion Longitude longitude Float conversion - geocoded latitude != NULL && longitude != NULL - geocodedAt Use createdAt if geocoded - geocodeProvider 'Legacy V1' or NULL - geocodeQuality NULL (unknown) SupportLevel supportLevel Map string to enum Notes notes Direct copy - contactName NULL - contactPhone NULL - contactEmail NULL - cutId NULL (assign later if needed) Created createdAt ISO 8601 timestamp - updatedAt Use createdAt - createdByUserId First MAP_ADMIN or SUPER_ADMIN

Address Parsing:

// V1 stored full address as single string\n// V2 requires structured fields\n\nconst parseAddress = (addressString) => {\n  // Example V1 address: \"123 Main St, Toronto, ON M5V 1A1\"\n  // Basic parsing (may need refinement for edge cases)\n\n  const parts = addressString.split(',').map(s => s.trim());\n\n  if (parts.length === 1) {\n    // Only street address\n    return {\n      address: parts[0],\n      city: null,\n      province: null,\n      postalCode: null\n    };\n  }\n\n  // Extract postal code (last part if matches pattern)\n  const postalRegex = /^[A-Z]\\d[A-Z]\\s?\\d[A-Z]\\d$/i;\n  let postalCode = null;\n  let province = null;\n  let city = null;\n\n  if (parts.length >= 3) {\n    const lastPart = parts[parts.length - 1];\n    const postalMatch = lastPart.match(/([A-Z]\\d[A-Z]\\s?\\d[A-Z]\\d)/i);\n\n    if (postalMatch) {\n      postalCode = postalMatch[1].replace(/\\s/, '').toUpperCase();\n      // Province usually before postal code\n      const provincePart = lastPart.replace(postalMatch[0], '').trim();\n      if (provincePart) {\n        province = provincePart;\n      } else if (parts.length >= 4) {\n        province = parts[parts.length - 2];\n      }\n    }\n\n    // City is second-to-last or third-to-last\n    if (parts.length >= 4 && province) {\n      city = parts[parts.length - 3];\n    } else if (parts.length >= 3) {\n      city = parts[parts.length - 2];\n    }\n  }\n\n  return {\n    address: parts[0],\n    city: city || null,\n    province: province || null,\n    postalCode: postalCode || null\n  };\n};\n\n// Example usage:\nparseAddress(\"123 Main St, Toronto, ON M5V 1A1\");\n// \u2192 { address: \"123 Main St\", city: \"Toronto\", province: \"ON\", postalCode: \"M5V1A1\" }\n

SupportLevel Enum Mapping:

const mapSupportLevel = (v1Level) => {\n  // V1 used inconsistent strings\n  const levelMap = {\n    'strong support': 'STRONG_SUPPORT',\n    'support': 'SUPPORT',\n    'undecided': 'UNDECIDED',\n    'oppose': 'OPPOSED',\n    'strong oppose': 'STRONG_OPPOSED',\n    'unknown': 'UNKNOWN',\n    'not home': 'NOT_HOME',\n    'moved': 'MOVED',\n    'deceased': 'DECEASED',\n    '': 'UNKNOWN'\n  };\n\n  return levelMap[v1Level?.toLowerCase()] || 'UNKNOWN';\n};\n

"},{"location":"v2/migration/data-migration/#export-v1-data","title":"Export V1 Data","text":""},{"location":"v2/migration/data-migration/#option-1-nocodb-api-export","title":"Option 1: NocoDB API Export","text":"

Script: scripts/export-v1-nocodb.js

#!/usr/bin/env node\nconst axios = require('axios');\nconst fs = require('fs').promises;\nconst path = require('path');\n\nconst NOCODB_URL = process.env.V1_NOCODB_URL || 'http://localhost:8080';\nconst NOCODB_TOKEN = process.env.V1_NOCODB_TOKEN;\nconst OUTPUT_DIR = process.env.OUTPUT_DIR || './v1-export';\n\nconst tables = [\n  'influence_users',\n  'login',\n  'campaigns',\n  'representatives',\n  'responses',\n  'response_upvotes',\n  'postal_code_cache',\n  'locations',\n  'shifts',\n  'shift_signups',\n  'cuts'\n];\n\nconst exportTable = async (tableName) => {\n  console.log(`Exporting ${tableName}...`);\n\n  let allRecords = [];\n  let offset = 0;\n  const limit = 100;\n  let hasMore = true;\n\n  while (hasMore) {\n    const response = await axios.get(\n      `${NOCODB_URL}/api/v1/db/data/v1/${tableName}`,\n      {\n        headers: { 'xc-token': NOCODB_TOKEN },\n        params: { limit, offset }\n      }\n    );\n\n    const records = response.data.list || [];\n    allRecords = allRecords.concat(records);\n\n    console.log(`  Fetched ${records.length} records (total: ${allRecords.length})`);\n\n    if (records.length < limit) {\n      hasMore = false;\n    } else {\n      offset += limit;\n    }\n  }\n\n  await fs.writeFile(\n    path.join(OUTPUT_DIR, `${tableName}.json`),\n    JSON.stringify(allRecords, null, 2)\n  );\n\n  console.log(`\u2713 Exported ${allRecords.length} records from ${tableName}`);\n  return allRecords.length;\n};\n\nconst main = async () => {\n  await fs.mkdir(OUTPUT_DIR, { recursive: true });\n\n  const counts = {};\n  for (const table of tables) {\n    try {\n      counts[table] = await exportTable(table);\n    } catch (error) {\n      console.error(`\u2717 Failed to export ${table}:`, error.message);\n      counts[table] = 0;\n    }\n  }\n\n  // Write summary\n  await fs.writeFile(\n    path.join(OUTPUT_DIR, 'export-summary.json'),\n    JSON.stringify({ exportedAt: new Date(), counts }, null, 2)\n  );\n\n  console.log('\\nExport Summary:');\n  console.table(counts);\n};\n\nmain().catch(console.error);\n

Usage:

cd /home/bunker-admin/changemaker.lite\nmkdir -p v1-export\n\n# Export from running V1 instance\nV1_NOCODB_URL=http://localhost:8080 \\\nV1_NOCODB_TOKEN=your-token \\\nOUTPUT_DIR=./v1-export \\\nnode scripts/export-v1-nocodb.js\n

"},{"location":"v2/migration/data-migration/#option-2-postgresql-direct-export","title":"Option 2: PostgreSQL Direct Export","text":"

If you have direct access to V1 PostgreSQL database:

# Export each table as CSV\ndocker compose -f docker-compose.v1.yml exec v1-postgres \\\n  psql -U nocodb -d nocodb -c \"\\COPY influence_users TO STDOUT CSV HEADER\" > v1-export/influence_users.csv\n\ndocker compose -f docker-compose.v1.yml exec v1-postgres \\\n  psql -U nocodb -d nocodb -c \"\\COPY login TO STDOUT CSV HEADER\" > v1-export/login.csv\n\ndocker compose -f docker-compose.v1.yml exec v1-postgres \\\n  psql -U nocodb -d nocodb -c \"\\COPY campaigns TO STDOUT CSV HEADER\" > v1-export/campaigns.csv\n\n# Repeat for all tables...\n
"},{"location":"v2/migration/data-migration/#backup-file-uploads","title":"Backup File Uploads","text":"
# V1 uploads directory\ntar -czf v1-uploads-backup.tar.gz ./uploads/\n\n# Verify archive\ntar -tzf v1-uploads-backup.tar.gz | head -20\n
"},{"location":"v2/migration/data-migration/#transform-data","title":"Transform Data","text":""},{"location":"v2/migration/data-migration/#user-transformation","title":"User Transformation","text":"

Script: scripts/transform-users.js

#!/usr/bin/env node\nconst fs = require('fs').promises;\nconst path = require('path');\n\nconst INPUT_DIR = process.env.INPUT_DIR || './v1-export';\nconst OUTPUT_DIR = process.env.OUTPUT_DIR || './v2-import';\n\nconst mapRole = (v1Role) => {\n  const roleMap = {\n    'admin': 'SUPER_ADMIN',\n    'moderator': 'INFLUENCE_ADMIN',\n    'user': 'USER'\n  };\n  return roleMap[v1Role] || 'USER';\n};\n\nconst transformUsers = async () => {\n  const influenceUsers = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'influence_users.json'), 'utf-8')\n  );\n  const loginUsers = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'login.json'), 'utf-8')\n  );\n\n  const merged = new Map();\n\n  // Add login users (has name field)\n  loginUsers.forEach(user => {\n    merged.set(user.Email.toLowerCase(), {\n      email: user.Email,\n      password: user.Password,\n      name: user.Name || null,\n      role: 'USER',\n      status: 'ACTIVE',\n      createdVia: 'STANDARD',\n      emailVerified: false,\n      createdAt: user.Created || new Date().toISOString(),\n      updatedAt: user.Created || new Date().toISOString()\n    });\n  });\n\n  // Override with influence_users (has role field)\n  influenceUsers.forEach(user => {\n    const existing = merged.get(user.Email.toLowerCase());\n    if (existing) {\n      existing.role = mapRole(user.Role);\n    } else {\n      merged.set(user.Email.toLowerCase(), {\n        email: user.Email,\n        password: user.Password,\n        name: null,\n        role: mapRole(user.Role),\n        status: 'ACTIVE',\n        createdVia: 'STANDARD',\n        emailVerified: false,\n        createdAt: user.Created || new Date().toISOString(),\n        updatedAt: user.Created || new Date().toISOString()\n      });\n    }\n  });\n\n  const users = Array.from(merged.values());\n\n  await fs.writeFile(\n    path.join(OUTPUT_DIR, 'users.json'),\n    JSON.stringify(users, null, 2)\n  );\n\n  console.log(`\u2713 Transformed ${users.length} users`);\n  console.log(`  influence_users: ${influenceUsers.length}`);\n  console.log(`  login: ${loginUsers.length}`);\n  console.log(`  merged: ${users.length}`);\n\n  return users;\n};\n\nconst main = async () => {\n  await fs.mkdir(OUTPUT_DIR, { recursive: true });\n  await transformUsers();\n};\n\nmain().catch(console.error);\n
"},{"location":"v2/migration/data-migration/#campaign-transformation","title":"Campaign Transformation","text":"

Script: scripts/transform-campaigns.js

#!/usr/bin/env node\nconst fs = require('fs').promises;\nconst path = require('path');\n\nconst INPUT_DIR = process.env.INPUT_DIR || './v1-export';\nconst OUTPUT_DIR = process.env.OUTPUT_DIR || './v2-import';\n\nconst transformCampaigns = async () => {\n  const v1Campaigns = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'campaigns.json'), 'utf-8')\n  );\n\n  // Note: createdByUserId must be populated after users are imported\n  // This transformation creates placeholder field\n  const campaigns = v1Campaigns.map(campaign => ({\n    title: campaign.Title,\n    description: campaign.Description || null,\n    slug: campaign.Slug,\n    active: Boolean(campaign.IsActive),\n    highlighted: false,\n    targetLevel: campaign.TargetLevel || null,\n    targetPosition: campaign.TargetPosition || null,\n    targetName: null,\n    targetEmail: null,\n    targetPostalCode: null,\n    customSubject: null,\n    customBody: null,\n    responseWallEnabled: true,\n    createdAt: campaign.Created || new Date().toISOString(),\n    updatedAt: campaign.Created || new Date().toISOString(),\n    _v1Id: campaign.Id // Keep for reference in import script\n  }));\n\n  await fs.writeFile(\n    path.join(OUTPUT_DIR, 'campaigns.json'),\n    JSON.stringify(campaigns, null, 2)\n  );\n\n  console.log(`\u2713 Transformed ${campaigns.length} campaigns`);\n  return campaigns;\n};\n\nconst main = async () => {\n  await fs.mkdir(OUTPUT_DIR, { recursive: true });\n  await transformCampaigns();\n};\n\nmain().catch(console.error);\n
"},{"location":"v2/migration/data-migration/#location-transformation","title":"Location Transformation","text":"

Script: scripts/transform-locations.js

#!/usr/bin/env node\nconst fs = require('fs').promises;\nconst path = require('path');\n\nconst INPUT_DIR = process.env.INPUT_DIR || './v1-export';\nconst OUTPUT_DIR = process.env.OUTPUT_DIR || './v2-import';\n\nconst parseAddress = (addressString) => {\n  if (!addressString) {\n    return { address: '', city: null, province: null, postalCode: null };\n  }\n\n  const parts = addressString.split(',').map(s => s.trim());\n\n  if (parts.length === 1) {\n    return {\n      address: parts[0],\n      city: null,\n      province: null,\n      postalCode: null\n    };\n  }\n\n  const postalRegex = /([A-Z]\\d[A-Z]\\s?\\d[A-Z]\\d)/i;\n  let postalCode = null;\n  let province = null;\n  let city = null;\n\n  if (parts.length >= 3) {\n    const lastPart = parts[parts.length - 1];\n    const postalMatch = lastPart.match(postalRegex);\n\n    if (postalMatch) {\n      postalCode = postalMatch[1].replace(/\\s/, '').toUpperCase();\n      const provincePart = lastPart.replace(postalMatch[0], '').trim();\n      if (provincePart) {\n        province = provincePart;\n      } else if (parts.length >= 4) {\n        province = parts[parts.length - 2];\n      }\n    }\n\n    if (parts.length >= 4 && province) {\n      city = parts[parts.length - 3];\n    } else if (parts.length >= 3) {\n      city = parts[parts.length - 2];\n    }\n  }\n\n  return {\n    address: parts[0],\n    city: city || null,\n    province: province || null,\n    postalCode: postalCode || null\n  };\n};\n\nconst mapSupportLevel = (v1Level) => {\n  const levelMap = {\n    'strong support': 'STRONG_SUPPORT',\n    'support': 'SUPPORT',\n    'undecided': 'UNDECIDED',\n    'oppose': 'OPPOSED',\n    'strong oppose': 'STRONG_OPPOSED',\n    'unknown': 'UNKNOWN',\n    'not home': 'NOT_HOME',\n    'moved': 'MOVED',\n    'deceased': 'DECEASED',\n    '': 'UNKNOWN'\n  };\n  return levelMap[v1Level?.toLowerCase()] || 'UNKNOWN';\n};\n\nconst transformLocations = async () => {\n  const v1Locations = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'locations.json'), 'utf-8')\n  );\n\n  const locations = v1Locations.map(loc => {\n    const { address, city, province, postalCode } = parseAddress(loc.Address);\n\n    const hasCoordinates = loc.Latitude != null && loc.Longitude != null;\n\n    return {\n      ...parseAddress(loc.Address),\n      country: 'Canada',\n      latitude: loc.Latitude ? parseFloat(loc.Latitude) : null,\n      longitude: loc.Longitude ? parseFloat(loc.Longitude) : null,\n      geocoded: hasCoordinates,\n      geocodedAt: hasCoordinates ? (loc.Created || new Date().toISOString()) : null,\n      geocodeProvider: hasCoordinates ? 'Legacy V1' : null,\n      geocodeQuality: null,\n      supportLevel: mapSupportLevel(loc.SupportLevel),\n      notes: loc.Notes || null,\n      contactName: null,\n      contactPhone: null,\n      contactEmail: null,\n      createdAt: loc.Created || new Date().toISOString(),\n      updatedAt: loc.Created || new Date().toISOString(),\n      _v1Id: loc.Id\n    };\n  });\n\n  await fs.writeFile(\n    path.join(OUTPUT_DIR, 'locations.json'),\n    JSON.stringify(locations, null, 2)\n  );\n\n  console.log(`\u2713 Transformed ${locations.length} locations`);\n\n  const geocodedCount = locations.filter(l => l.geocoded).length;\n  console.log(`  Geocoded: ${geocodedCount} (${(geocodedCount/locations.length*100).toFixed(1)}%)`);\n\n  return locations;\n};\n\nconst main = async () => {\n  await fs.mkdir(OUTPUT_DIR, { recursive: true });\n  await transformLocations();\n};\n\nmain().catch(console.error);\n
"},{"location":"v2/migration/data-migration/#import-v2-data","title":"Import V2 Data","text":""},{"location":"v2/migration/data-migration/#import-script","title":"Import Script","text":"

Script: scripts/import-v2-data.js

#!/usr/bin/env node\nconst { PrismaClient } = require('@prisma/client');\nconst fs = require('fs').promises;\nconst path = require('path');\n\nconst prisma = new PrismaClient();\nconst INPUT_DIR = process.env.INPUT_DIR || './v2-import';\n\nconst importUsers = async () => {\n  const users = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'users.json'), 'utf-8')\n  );\n\n  console.log(`Importing ${users.length} users...`);\n\n  const created = [];\n  for (const user of users) {\n    try {\n      const newUser = await prisma.user.create({ data: user });\n      created.push(newUser);\n    } catch (error) {\n      if (error.code === 'P2002') {\n        console.warn(`  \u26a0 User ${user.email} already exists, skipping`);\n      } else {\n        console.error(`  \u2717 Failed to import user ${user.email}:`, error.message);\n      }\n    }\n  }\n\n  console.log(`\u2713 Imported ${created.length}/${users.length} users`);\n  return created;\n};\n\nconst importCampaigns = async () => {\n  const campaigns = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'campaigns.json'), 'utf-8')\n  );\n\n  // Find first SUPER_ADMIN user\n  const admin = await prisma.user.findFirst({\n    where: { role: 'SUPER_ADMIN' }\n  });\n\n  if (!admin) {\n    throw new Error('No SUPER_ADMIN user found. Import users first.');\n  }\n\n  console.log(`Importing ${campaigns.length} campaigns (creator: ${admin.email})...`);\n\n  const created = [];\n  for (const campaign of campaigns) {\n    try {\n      const { _v1Id, ...data } = campaign;\n      const newCampaign = await prisma.campaign.create({\n        data: {\n          ...data,\n          createdByUserId: admin.id\n        }\n      });\n      created.push(newCampaign);\n    } catch (error) {\n      if (error.code === 'P2002') {\n        console.warn(`  \u26a0 Campaign ${campaign.slug} already exists, skipping`);\n      } else {\n        console.error(`  \u2717 Failed to import campaign ${campaign.title}:`, error.message);\n      }\n    }\n  }\n\n  console.log(`\u2713 Imported ${created.length}/${campaigns.length} campaigns`);\n  return created;\n};\n\nconst importLocations = async () => {\n  const locations = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'locations.json'), 'utf-8')\n  );\n\n  // Find first MAP_ADMIN or SUPER_ADMIN user\n  const admin = await prisma.user.findFirst({\n    where: { OR: [{ role: 'MAP_ADMIN' }, { role: 'SUPER_ADMIN' }] }\n  });\n\n  if (!admin) {\n    throw new Error('No MAP_ADMIN or SUPER_ADMIN user found. Import users first.');\n  }\n\n  console.log(`Importing ${locations.length} locations (creator: ${admin.email})...`);\n\n  const created = [];\n  for (const location of locations) {\n    try {\n      const { _v1Id, ...data } = location;\n      const newLocation = await prisma.location.create({\n        data: {\n          ...data,\n          createdByUserId: admin.id\n        }\n      });\n      created.push(newLocation);\n    } catch (error) {\n      console.error(`  \u2717 Failed to import location ${location.address}:`, error.message);\n    }\n  }\n\n  console.log(`\u2713 Imported ${created.length}/${locations.length} locations`);\n  return created;\n};\n\nconst main = async () => {\n  try {\n    console.log('Starting V2 data import...\\n');\n\n    await importUsers();\n    console.log();\n\n    await importCampaigns();\n    console.log();\n\n    await importLocations();\n    console.log();\n\n    console.log('\u2713 Import complete!');\n  } catch (error) {\n    console.error('Import failed:', error);\n    process.exit(1);\n  } finally {\n    await prisma.$disconnect();\n  }\n};\n\nmain();\n

Usage:

cd /home/bunker-admin/changemaker.lite\n\n# Ensure V2 database is running and migrated\ndocker compose up -d v2-postgres\ndocker compose exec api npx prisma migrate deploy\n\n# Run import\nINPUT_DIR=./v2-import node scripts/import-v2-data.js\n

"},{"location":"v2/migration/data-migration/#validate-migration","title":"Validate Migration","text":""},{"location":"v2/migration/data-migration/#validation-script","title":"Validation Script","text":"

Script: scripts/validate-migration.js

#!/usr/bin/env node\nconst { PrismaClient } = require('@prisma/client');\nconst fs = require('fs').promises;\nconst path = require('path');\n\nconst prisma = new PrismaClient();\nconst V1_EXPORT_DIR = './v1-export';\n\nconst validateCounts = async () => {\n  console.log('Validating record counts...\\n');\n\n  const v1Summary = JSON.parse(\n    await fs.readFile(path.join(V1_EXPORT_DIR, 'export-summary.json'), 'utf-8')\n  );\n\n  const v2Counts = {\n    users: await prisma.user.count(),\n    campaigns: await prisma.campaign.count(),\n    locations: await prisma.location.count(),\n    shifts: await prisma.shift.count(),\n    representatives: await prisma.representative.count()\n  };\n\n  const comparison = [\n    {\n      Table: 'Users',\n      V1: v1Summary.counts.influence_users + v1Summary.counts.login,\n      V2: v2Counts.users,\n      Match: '\u2248' // Approximate due to deduplication\n    },\n    {\n      Table: 'Campaigns',\n      V1: v1Summary.counts.campaigns,\n      V2: v2Counts.campaigns,\n      Match: v1Summary.counts.campaigns === v2Counts.campaigns ? '\u2713' : '\u2717'\n    },\n    {\n      Table: 'Locations',\n      V1: v1Summary.counts.locations,\n      V2: v2Counts.locations,\n      Match: v1Summary.counts.locations === v2Counts.locations ? '\u2713' : '\u2717'\n    },\n    {\n      Table: 'Shifts',\n      V1: v1Summary.counts.shifts,\n      V2: v2Counts.shifts,\n      Match: v1Summary.counts.shifts === v2Counts.shifts ? '\u2713' : '\u2717'\n    },\n    {\n      Table: 'Representatives',\n      V1: v1Summary.counts.representatives,\n      V2: v2Counts.representatives,\n      Match: v1Summary.counts.representatives === v2Counts.representatives ? '\u2713' : '\u2717'\n    }\n  ];\n\n  console.table(comparison);\n};\n\nconst validateSampleData = async () => {\n  console.log('\\nValidating sample data integrity...\\n');\n\n  // Check first user\n  const firstUser = await prisma.user.findFirst({\n    orderBy: { createdAt: 'asc' }\n  });\n  console.log('First User:', {\n    email: firstUser.email,\n    role: firstUser.role,\n    hasPassword: firstUser.password?.startsWith('$2b$') ? 'Yes (bcrypt)' : 'No'\n  });\n\n  // Check first campaign\n  const firstCampaign = await prisma.campaign.findFirst({\n    include: { createdBy: { select: { email: true } } },\n    orderBy: { createdAt: 'asc' }\n  });\n  console.log('First Campaign:', {\n    title: firstCampaign.title,\n    slug: firstCampaign.slug,\n    creator: firstCampaign.createdBy.email\n  });\n\n  // Check first location\n  const firstLocation = await prisma.location.findFirst({\n    orderBy: { createdAt: 'asc' }\n  });\n  console.log('First Location:', {\n    address: firstLocation.address,\n    city: firstLocation.city,\n    geocoded: firstLocation.geocoded,\n    supportLevel: firstLocation.supportLevel\n  });\n\n  // Geocoding statistics\n  const totalLocations = await prisma.location.count();\n  const geocodedLocations = await prisma.location.count({\n    where: { geocoded: true }\n  });\n  console.log('\\nGeocoding Stats:', {\n    total: totalLocations,\n    geocoded: geocodedLocations,\n    percentage: `${(geocodedLocations / totalLocations * 100).toFixed(1)}%`\n  });\n};\n\nconst main = async () => {\n  try {\n    await validateCounts();\n    await validateSampleData();\n\n    console.log('\\n\u2713 Validation complete');\n  } catch (error) {\n    console.error('Validation failed:', error);\n    process.exit(1);\n  } finally {\n    await prisma.$disconnect();\n  }\n};\n\nmain();\n
"},{"location":"v2/migration/data-migration/#special-cases","title":"Special Cases","text":""},{"location":"v2/migration/data-migration/#handling-duplicate-emails","title":"Handling Duplicate Emails","text":"

During user merge, you may encounter duplicate emails:

// Option 1: Keep first occurrence, log duplicates\nconst handleDuplicates = (users) => {\n  const seen = new Set();\n  const duplicates = [];\n\n  const unique = users.filter(user => {\n    if (seen.has(user.email.toLowerCase())) {\n      duplicates.push(user);\n      return false;\n    }\n    seen.add(user.email.toLowerCase());\n    return true;\n  });\n\n  if (duplicates.length > 0) {\n    console.warn(`Found ${duplicates.length} duplicate emails:`);\n    duplicates.forEach(d => console.warn(`  - ${d.email}`));\n  }\n\n  return unique;\n};\n\n// Option 2: Append suffix to duplicates\nconst handleDuplicatesWithSuffix = (users) => {\n  const counts = new Map();\n\n  return users.map(user => {\n    const email = user.email.toLowerCase();\n    const count = counts.get(email) || 0;\n    counts.set(email, count + 1);\n\n    if (count > 0) {\n      const [local, domain] = email.split('@');\n      return {\n        ...user,\n        email: `${local}+v1dup${count}@${domain}`\n      };\n    }\n\n    return user;\n  });\n};\n
"},{"location":"v2/migration/data-migration/#migrating-representative-cache","title":"Migrating Representative Cache","text":"

Representative cache can be rebuilt from Represent API, but to preserve it:

const transformRepresentatives = async () => {\n  const v1Reps = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'representatives.json'), 'utf-8')\n  );\n\n  const reps = v1Reps.map(rep => ({\n    name: rep.Name,\n    email: rep.Email,\n    district: rep.District,\n    party: rep.Party,\n    level: rep.Level,\n    photoUrl: rep.PhotoUrl || null,\n    postalCodes: rep.PostalCodes ? JSON.parse(rep.PostalCodes) : [],\n    createdAt: rep.Created || new Date().toISOString(),\n    updatedAt: rep.Updated || new Date().toISOString()\n  }));\n\n  await fs.writeFile(\n    path.join(OUTPUT_DIR, 'representatives.json'),\n    JSON.stringify(reps, null, 2)\n  );\n\n  return reps;\n};\n
"},{"location":"v2/migration/data-migration/#migrating-shift-signups","title":"Migrating Shift Signups","text":"

V1 may have embedded signups; V2 uses separate ShiftSignup table:

const transformShiftSignups = async () => {\n  const v1Shifts = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'shifts.json'), 'utf-8')\n  );\n\n  const signups = [];\n\n  v1Shifts.forEach(shift => {\n    if (shift.Signups && Array.isArray(shift.Signups)) {\n      shift.Signups.forEach(signup => {\n        signups.push({\n          shiftId: shift.Id, // V1 ID, will need mapping in import\n          userId: signup.UserId, // V1 ID, will need mapping\n          status: 'CONFIRMED',\n          notes: signup.Notes || null,\n          confirmedAt: signup.CreatedAt || new Date().toISOString(),\n          createdAt: signup.CreatedAt || new Date().toISOString()\n        });\n      });\n    }\n  });\n\n  await fs.writeFile(\n    path.join(OUTPUT_DIR, 'shift-signups.json'),\n    JSON.stringify(signups, null, 2)\n  );\n\n  return signups;\n};\n\n// Import with ID mapping\nconst importShiftSignups = async (idMappings) => {\n  const signups = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'shift-signups.json'), 'utf-8')\n  );\n\n  for (const signup of signups) {\n    const v2ShiftId = idMappings.shifts[signup.shiftId];\n    const v2UserId = idMappings.users[signup.userId];\n\n    if (!v2ShiftId || !v2UserId) {\n      console.warn(`Skipping signup: shift ${signup.shiftId} or user ${signup.userId} not found`);\n      continue;\n    }\n\n    await prisma.shiftSignup.create({\n      data: {\n        shiftId: v2ShiftId,\n        userId: v2UserId,\n        status: signup.status,\n        notes: signup.notes,\n        confirmedAt: signup.confirmedAt,\n        createdAt: signup.createdAt\n      }\n    });\n  }\n};\n
"},{"location":"v2/migration/data-migration/#testing-migration","title":"Testing Migration","text":""},{"location":"v2/migration/data-migration/#pre-production-test-migration","title":"Pre-Production Test Migration","text":"

Before production migration, perform full test on staging:

# 1. Clone production V1 data to staging\n./scripts/backup.sh\nscp backups/latest.tar.gz staging-server:/tmp/\n\n# 2. Restore V1 on staging\nssh staging-server\ncd /opt/changemaker-lite\ntar -xzf /tmp/latest.tar.gz -C ./\ndocker compose -f docker-compose.v1.yml up -d\n\n# 3. Export V1 data\ndocker compose -f docker-compose.v1.yml exec influence-app node /app/scripts/export-data.js\n\n# 4. Set up V2 on staging\ngit checkout v2\ndocker compose up -d v2-postgres redis\ndocker compose exec api npx prisma migrate deploy\n\n# 5. Transform and import\nnode scripts/transform-users.js\nnode scripts/transform-campaigns.js\nnode scripts/transform-locations.js\nnode scripts/import-v2-data.js\n\n# 6. Validate\nnode scripts/validate-migration.js\n\n# 7. Test critical workflows\n./scripts/test-v2-workflows.sh\n
"},{"location":"v2/migration/data-migration/#test-critical-workflows","title":"Test Critical Workflows","text":"

Script: scripts/test-v2-workflows.sh

#!/bin/bash\nset -e\n\nAPI_URL=\"http://localhost:4000\"\nADMIN_TOKEN=\"\"\n\necho \"Testing V2 Critical Workflows\"\necho \"==============================\"\n\n# 1. Admin Login\necho -n \"1. Admin login... \"\nLOGIN_RESPONSE=$(curl -s -X POST \"$API_URL/api/auth/login\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\":\"admin@example.com\",\"password\":\"Admin123!\"}')\n\nADMIN_TOKEN=$(echo $LOGIN_RESPONSE | jq -r '.data.accessToken')\n\nif [ \"$ADMIN_TOKEN\" != \"null\" ] && [ -n \"$ADMIN_TOKEN\" ]; then\n  echo \"\u2713\"\nelse\n  echo \"\u2717 Failed\"\n  exit 1\nfi\n\n# 2. List Campaigns\necho -n \"2. List campaigns... \"\nCAMPAIGNS=$(curl -s \"$API_URL/api/influence/campaigns\" \\\n  -H \"Authorization: Bearer $ADMIN_TOKEN\")\n\nCAMPAIGN_COUNT=$(echo $CAMPAIGNS | jq '.data | length')\necho \"\u2713 ($CAMPAIGN_COUNT campaigns)\"\n\n# 3. Representative Lookup\necho -n \"3. Representative lookup (M5V 1A1)... \"\nREPS=$(curl -s -X POST \"$API_URL/api/influence/representatives/lookup\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"postalCode\":\"M5V1A1\"}')\n\nREP_COUNT=$(echo $REPS | jq '.data | length')\necho \"\u2713 ($REP_COUNT representatives)\"\n\n# 4. List Locations\necho -n \"4. List locations... \"\nLOCATIONS=$(curl -s \"$API_URL/api/map/locations\" \\\n  -H \"Authorization: Bearer $ADMIN_TOKEN\")\n\nLOCATION_COUNT=$(echo $LOCATIONS | jq '.data | length')\necho \"\u2713 ($LOCATION_COUNT locations)\"\n\n# 5. Send Test Email\necho -n \"5. Queue test email... \"\nEMAIL_RESPONSE=$(curl -s -X POST \"$API_URL/api/influence/campaign-emails/send-email\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"campaignId\":\"'$(echo $CAMPAIGNS | jq -r '.data[0].id')'\",\n    \"postalCode\":\"M5V1A1\",\n    \"senderName\":\"Test User\",\n    \"senderEmail\":\"test@example.com\"\n  }')\n\nif echo $EMAIL_RESPONSE | jq -e '.success' > /dev/null; then\n  echo \"\u2713\"\nelse\n  echo \"\u2717 Failed\"\nfi\n\necho\necho \"All critical workflows passed \u2713\"\n
"},{"location":"v2/migration/data-migration/#production-migration","title":"Production Migration","text":""},{"location":"v2/migration/data-migration/#step-by-step-procedure","title":"Step-by-Step Procedure","text":""},{"location":"v2/migration/data-migration/#phase-1-preparation-1-2-days-before","title":"Phase 1: Preparation (1-2 days before)","text":"
  1. Announce Downtime Window

    Subject: Scheduled Maintenance - System Upgrade\n\nWe will be performing a major system upgrade on [DATE] at [TIME].\n\nExpected downtime: 15-30 minutes\n\nWhat to expect:\n- All users will be logged out\n- You will need to re-login after the upgrade\n- Your data and passwords remain unchanged\n\nPlease save any unsaved work before [TIME].\n

  2. Backup V1

    ./scripts/backup.sh --include-uploads\n\n# Verify backup\ntar -tzf backups/changemaker-v1-$(date +%Y%m%d).tar.gz | head -20\n

  3. Test V2 on Staging (use procedure above)

"},{"location":"v2/migration/data-migration/#phase-2-export-t-60min","title":"Phase 2: Export (T-60min)","text":"
  1. Enable V1 Read-Only Mode

    # Stop V1 write services\ndocker compose -f docker-compose.v1.yml stop influence-app map-app\n\n# Keep database running for export\n

  2. Export V1 Data

    V1_NOCODB_URL=http://localhost:8080 \\\nV1_NOCODB_TOKEN=$(cat .env | grep NOCODB_API_TOKEN | cut -d= -f2) \\\nnode scripts/export-v1-nocodb.js\n\n# Verify export\nls -lh v1-export/\n

"},{"location":"v2/migration/data-migration/#phase-3-transform-t-30min","title":"Phase 3: Transform (T-30min)","text":"
  1. Transform Data
    node scripts/transform-users.js\nnode scripts/transform-campaigns.js\nnode scripts/transform-locations.js\nnode scripts/transform-shifts.js\n\n# Verify transformed data\nls -lh v2-import/\n
"},{"location":"v2/migration/data-migration/#phase-4-import-t-15min","title":"Phase 4: Import (T-15min)","text":"
  1. Stop V1 Completely

    docker compose -f docker-compose.v1.yml down\n

  2. Start V2 Database

    docker compose up -d v2-postgres redis\ndocker compose exec api npx prisma migrate deploy\n

  3. Import Data

    node scripts/import-v2-data.js | tee migration.log\n

  4. Validate Import

    node scripts/validate-migration.js\n

"},{"location":"v2/migration/data-migration/#phase-5-launch-v2-t0min","title":"Phase 5: Launch V2 (T+0min)","text":"
  1. Start All V2 Services

    docker compose up -d\n\n# Wait for health checks\nsleep 30\n\n# Verify all healthy\ndocker compose ps\n

  2. Smoke Test

    ./scripts/test-v2-workflows.sh\n

  3. Update DNS/Tunnel

    • Pangolin: Update endpoint in admin
    • Cloudflare: Update tunnel configuration
    • Manual DNS: Update A/CNAME records
"},{"location":"v2/migration/data-migration/#phase-6-monitor-t15min-to-t24hr","title":"Phase 6: Monitor (T+15min to T+24hr)","text":"
  1. Watch Logs

    docker compose logs -f api admin\n

  2. Monitor Metrics

    • Open Grafana: http://localhost:3001
    • Check API Performance dashboard
    • Watch for error spikes
  3. Test User Logins

    • Admin login
    • Regular user login
    • Temp user creation (shift signup)
  4. Announce Migration Complete

    Subject: System Upgrade Complete\n\nOur system upgrade is complete! You can now log in at:\nhttps://app.cmlite.org\n\nYour username and password remain unchanged.\n\nNew features available:\n- [List new V2 features]\n\nIf you experience any issues, please contact support@cmlite.org.\n

"},{"location":"v2/migration/data-migration/#rollback-procedures","title":"Rollback Procedures","text":"

If migration fails, follow these steps:

"},{"location":"v2/migration/data-migration/#emergency-rollback-t0-to-t2hr","title":"Emergency Rollback (T+0 to T+2hr)","text":"
# 1. Stop V2 services\ndocker compose down\n\n# 2. Restore V1 services\ndocker compose -f docker-compose.v1.yml up -d\n\n# 3. Restore V1 database from backup (if modified)\ndocker compose -f docker-compose.v1.yml exec -T v1-postgres \\\n  psql -U nocodb nocodb < backups/v1-postgres-backup.sql\n\n# 4. Verify V1 operational\ncurl -I http://localhost:3333/health\n\n# 5. Revert DNS/tunnel\n\n# 6. Announce rollback\necho \"Migration has been rolled back. V1 is operational.\" | \\\n  mail -s \"Migration Rollback\" admin@cmlite.org\n
"},{"location":"v2/migration/data-migration/#post-rollback-analysis","title":"Post-Rollback Analysis","text":"
  1. Review Migration Logs

    cat migration.log | grep ERROR\n

  2. Identify Root Cause

  3. Data transformation errors?
  4. Database constraint violations?
  5. Application bugs?

  6. Fix Issues on Staging

  7. Update transformation scripts
  8. Test again on staging
  9. Validate thoroughly

  10. Reschedule Migration

  11. New downtime window
  12. Communicate lessons learned
"},{"location":"v2/migration/data-migration/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/migration/data-migration/#issue-prisma-unique-constraint-violation","title":"Issue: Prisma Unique Constraint Violation","text":"

Error: P2002: Unique constraint failed on the constraint: unique_email

Cause: Duplicate emails in merged user data.

Solution:

// Before import, deduplicate\nconst users = JSON.parse(await fs.readFile('v2-import/users.json', 'utf-8'));\nconst unique = handleDuplicates(users);\nawait fs.writeFile('v2-import/users.json', JSON.stringify(unique, null, 2));\n

"},{"location":"v2/migration/data-migration/#issue-foreign-key-constraint-violation","title":"Issue: Foreign Key Constraint Violation","text":"

Error: P2003: Foreign key constraint failed on the field: createdByUserId

Cause: Campaign references user that doesn't exist (import order).

Solution: Always import in order: 1. Users first 2. Campaigns (references users) 3. Locations (references users) 4. Shifts, responses, etc.

"},{"location":"v2/migration/data-migration/#issue-bcrypt-hashes-not-working","title":"Issue: Bcrypt Hashes Not Working","text":"

Symptoms: Users can't login after migration despite correct password.

Cause: Password field truncated or corrupted.

Diagnosis:

-- Check password hash format\nSELECT email, LEFT(password, 10), LENGTH(password) FROM \"User\" LIMIT 5;\n\n-- Should be: \"$2b$10...\", length 60\n

Solution:

# Re-import users, ensure password field is text type\n# Or batch reset passwords:\ndocker compose exec api node scripts/reset-all-passwords.js\n

"},{"location":"v2/migration/data-migration/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/migration/data-migration/#next-steps","title":"Next Steps","text":"

After successful migration:

  1. Configure V2 Settings
  2. Site Settings
  3. Map Settings
  4. Email Configuration

  5. Train Administrators

  6. Admin Guide
  7. Campaign Management
  8. Volunteer Canvassing

  9. Enable New Features

  10. Landing Page Builder
  11. Email Templates
  12. Media Library

  13. Set Up Monitoring

  14. Observability Guide
  15. Backup Procedures

Migration Complete

Congratulations on completing your V2 migration! Welcome to the modern Changemaker Lite platform.

"},{"location":"v2/migration/feature-parity/","title":"Feature Parity: V1 vs V2","text":"

This document provides a comprehensive comparison of features between Changemaker Lite V1 and V2, including feature status, implementation differences, and migration priorities.

"},{"location":"v2/migration/feature-parity/#overview","title":"Overview","text":"

V2 achieves 100% feature parity with V1 core functionality and adds significant new capabilities. Some V1 features are implemented differently (better!) in V2.

V2 Feature Status

"},{"location":"v2/migration/feature-parity/#feature-comparison-matrix","title":"Feature Comparison Matrix","text":""},{"location":"v2/migration/feature-parity/#core-features","title":"Core Features","text":"Feature V1 V2 Status Notes Email Advocacy Campaigns \u2705 \u2705 Enhanced V2 adds BullMQ queue, Listmonk sync Representative Lookup \u2705 \u2705 Enhanced V2 adds caching, multi-level support Response Wall \u2705 \u2705 Enhanced V2 adds moderation, upvoting, verification Location Management \u2705 \u2705 Enhanced V2 adds structured address, geocoding quality Geocoding \u2705 \u2705 Enhanced V1: Nominatim only \u2192 V2: 6 providers Volunteer Shifts \u2705 \u2705 Enhanced V2 adds cut assignments, status tracking Public Shift Signup \u2705 \u2705 Same V2 creates temp users automatically User Management \u2705 \u2705 Enhanced V2 adds unified user model, RBAC Admin Authentication \u2705 \u2705 Changed V1: Sessions \u2192 V2: JWT"},{"location":"v2/migration/feature-parity/#map-features","title":"Map Features","text":"Feature V1 V2 Status Notes Location Map (Public) \u2705 \u2705 Enhanced V2 adds color-coded markers, cut overlays Location Map (Admin) \u2705 \u2705 Enhanced V2 adds click-to-add, move mode, geolocate Cuts (Territories) \u2705 \u2705 Enhanced V2 adds drawing mode, point-in-polygon CSV Import/Export \u2705 \u2705 Enhanced V2 adds flexible column mapping Bulk Geocoding \u274c \u2705 New V2 adds bulk geocode endpoint Reverse Geocoding \u274c \u2705 New V2 adds lat/lng \u2192 address lookup Walk Sheets \u274c \u2705 New V2 adds printable walk sheets with QR codes Cut Export \u274c \u2705 New V2 adds printable location reports NAR Import \u274c \u2705 New V2 adds Canadian electoral data import Data Quality Dashboard \u274c \u2705 New V2 adds geocoding quality metrics"},{"location":"v2/migration/feature-parity/#canvassing-features","title":"Canvassing Features","text":"Feature V1 V2 Status Notes Canvassing System \u274c \u2705 New V2 adds full canvassing workflow GPS Tracking \u274c \u2705 New V2 adds volunteer GPS trail recording Walking Routes \u274c \u2705 New V2 adds optimized route algorithm Visit Recording \u274c \u2705 New V2 adds outcome tracking, notes Canvass Dashboard \u274c \u2705 New V2 adds admin analytics, leaderboards Volunteer Portal \u274c \u2705 New V2 adds dedicated volunteer interface Activity History \u274c \u2705 New V2 adds visit history, stats"},{"location":"v2/migration/feature-parity/#content-management","title":"Content Management","text":"Feature V1 V2 Status Notes Landing Page Builder \u274c \u2705 New V2 adds GrapesJS editor Block Library \u274c \u2705 New V2 adds reusable content blocks MkDocs Export \u274c \u2705 New V2 adds static site generation Email Templates \u274c \u2705 New V2 adds template system with versioning Template Variables \u274c \u2705 New V2 adds dynamic content substitution"},{"location":"v2/migration/feature-parity/#media-management","title":"Media Management","text":"Feature V1 V2 Status Notes Video Library \u274c \u2705 New V2 adds video CRUD, categories Video Upload \u274c \u2705 New V2 adds upload with metadata extraction Public Gallery \u274c \u2705 New V2 adds public video gallery Reactions \u274c \u2705 New V2 adds 6 emoji reactions Video Sharing \u274c \u2705 New V2 adds lock/unlock system"},{"location":"v2/migration/feature-parity/#email-newsletters","title":"Email & Newsletters","text":"Feature V1 V2 Status Notes SMTP Email Sending \u2705 \u2705 Enhanced V2 adds BullMQ queue, test mode Email Queue \u2705 \u2705 Enhanced V1: Bull \u2192 V2: BullMQ with monitoring Email Tracking \u2705 \u2705 Enhanced V2 adds sent/failed stats per campaign Listmonk Integration \u274c \u2705 New V2 adds newsletter sync Subscriber Management \u274c \u2705 New V2 adds campaign participant \u2192 list sync"},{"location":"v2/migration/feature-parity/#monitoring-devops","title":"Monitoring & DevOps","text":"Feature V1 V2 Status Notes Prometheus Metrics \u274c \u2705 New V2 adds 12 custom cm_* metrics Grafana Dashboards \u274c \u2705 New V2 adds 3 pre-configured dashboards Alertmanager \u274c \u2705 New V2 adds alert rules, Gotify integration Health Checks \u274c \u2705 New V2 adds Docker healthchecks (7 services) Backup Script \u2705 \u2705 Enhanced V2 adds PostgreSQL + Listmonk + uploads Observability Dashboard \u274c \u2705 New V2 adds admin observability page"},{"location":"v2/migration/feature-parity/#platform-services","title":"Platform Services","text":"Feature V1 V2 Status Notes NocoDB \u2705 (data layer) \u2705 (read-only) Changed V2 uses Prisma, NocoDB for browsing Redis \u2705 \u2705 Enhanced V2 adds authentication required PostgreSQL \u2705 (NocoDB) \u2705 (direct) Enhanced V2 uses PostgreSQL 16 directly MkDocs \u274c \u2705 New V2 adds documentation site Code Server \u274c \u2705 New V2 adds web-based IDE n8n \u274c \u2705 New V2 adds workflow automation Gitea \u274c \u2705 New V2 adds Git repository hosting Homepage \u274c \u2705 New V2 adds service dashboard Pangolin Tunnel \u274c \u2705 New V2 adds self-hosted tunnel alternative Cloudflare Tunnel \u2705 \u274c Removed Replaced by Pangolin"},{"location":"v2/migration/feature-parity/#detailed-feature-comparisons","title":"Detailed Feature Comparisons","text":""},{"location":"v2/migration/feature-parity/#1-email-advocacy-campaigns","title":"1. Email Advocacy Campaigns","text":""},{"location":"v2/migration/feature-parity/#v1-implementation","title":"V1 Implementation","text":"
Features:\n- Create campaign (title, description, slug)\n- Target representatives via postal code lookup\n- Send emails to representatives (SMTP)\n- Track sent emails\n- Basic campaign listing\n\nTechnology:\n- NocoDB tables (campaigns, campaign_emails)\n- Bull job queue for async sending\n- Nodemailer SMTP\n- Represent API integration\n
"},{"location":"v2/migration/feature-parity/#v2-implementation","title":"V2 Implementation","text":"
Features:\n- All V1 features plus:\n  - Highlighted campaigns (featured on homepage)\n  - Response wall toggle per campaign\n  - Custom email subject/body templates\n  - Target filtering (level, position, name, email, postal code)\n  - Email stats dashboard (queued, sent, failed, mailto clicks)\n  - BullMQ queue admin (pause, resume, retry failed)\n  - Listmonk newsletter sync (campaign participants \u2192 list)\n  - Public campaign gallery\n  - Public campaign detail page\n\nTechnology:\n- Prisma models (Campaign, CampaignEmail, Representative, etc.)\n- BullMQ job queue with monitoring\n- Nodemailer SMTP + MailHog test mode\n- Represent API client with in-memory rate limiter (55/min)\n- Redis cache for representatives (60min TTL)\n

Migration Impact: V1 campaigns migrate directly. New fields default to sensible values.

"},{"location":"v2/migration/feature-parity/#2-representative-lookup","title":"2. Representative Lookup","text":""},{"location":"v2/migration/feature-parity/#v1-implementation_1","title":"V1 Implementation","text":"
Features:\n- Lookup by postal code (Represent API)\n- Display representative name, email, district, party\n- No caching (every lookup hits API)\n\nLimitations:\n- Rate limit issues (API throttling)\n- Slow response times\n- No offline capability\n
"},{"location":"v2/migration/feature-parity/#v2-implementation_1","title":"V2 Implementation","text":"
Features:\n- All V1 features plus:\n  - Redis cache (60min TTL)\n  - Fire-and-forget cache writes (non-blocking)\n  - Multi-level support (federal, provincial, municipal)\n  - Representative admin (view cache, stats, delete)\n  - Cache stats (total, by level, by party)\n  - Health check endpoint\n\nPerformance:\n- First lookup: ~500ms (API call + cache write)\n- Cached lookup: ~20ms (Redis)\n- Rate limiter: 55 requests/min (Represent API limit)\n

Migration Impact: Representative cache can be migrated or rebuilt from API.

"},{"location":"v2/migration/feature-parity/#3-location-management-geocoding","title":"3. Location Management & Geocoding","text":""},{"location":"v2/migration/feature-parity/#v1-implementation_2","title":"V1 Implementation","text":"
Features:\n- Create location (single address field)\n- Geocode via Nominatim (single provider)\n- Support level (string field)\n- Public map display (circle markers)\n\nLimitations:\n- No structured address (city, province separate)\n- Single geocoding provider (Nominatim)\n- No geocoding quality tracking\n- No bulk operations\n
"},{"location":"v2/migration/feature-parity/#v2-implementation_2","title":"V2 Implementation","text":"
Features:\n- All V1 features plus:\n  - Structured address (street, city, province, postal code, country)\n  - Multi-provider geocoding (6 providers with fallback):\n    1. Nominatim (default, free)\n    2. ArcGIS (enterprise)\n    3. Photon (European focus)\n    4. Mapbox (if API key provided)\n    5. Google (if API key provided)\n    6. OpenCage (if API key provided)\n  - Geocoding metadata (provider, quality, timestamp)\n  - Bulk geocoding endpoint (100 at a time)\n  - Reverse geocoding (lat/lng \u2192 address)\n  - CSV import with flexible column mapping\n  - CSV export with filters\n  - Location history (edit trail)\n  - Contact fields (name, phone, email)\n  - NAR import (Canadian electoral data, 50k+ locations)\n  - Data quality dashboard (geocoding success rate by provider)\n  - Click-to-add location on map\n  - Drag-to-move location on map\n  - Geolocate button (browser location)\n  - Fullscreen map mode\n\nTechnology:\n- Prisma Location model (structured schema)\n- Multi-provider geocoding service with retry logic\n- PostgreSQL spatial extensions (future: PostGIS)\n- React Leaflet map components\n

Migration Impact: V1 single address field parsed into structured fields. Geocoding metadata added.

"},{"location":"v2/migration/feature-parity/#4-volunteer-shifts","title":"4. Volunteer Shifts","text":""},{"location":"v2/migration/feature-parity/#v1-implementation_3","title":"V1 Implementation","text":"
Features:\n- Create shift (name, start/end time, location, capacity)\n- Public signup form\n- Email confirmation\n- Admin view signups\n\nLimitations:\n- No cut assignment (shifts not linked to territories)\n- No signup status tracking\n- No volunteer portal\n
"},{"location":"v2/migration/feature-parity/#v2-implementation_3","title":"V2 Implementation","text":"
Features:\n- All V1 features plus:\n  - Cut assignment (link shift to territory)\n  - Signup status (PENDING, CONFIRMED, CANCELLED, COMPLETED, NO_SHOW)\n  - Shift requirements field\n  - Temp user creation (public signup creates USER with 30-day expiry)\n  - Signup cancellation (volunteer self-service)\n  - Admin signup management (update status, notes)\n  - Email all signups (broadcast to shift volunteers)\n  - Shift stats (total shifts, upcoming, signups by status)\n  - Volunteer portal (view assigned shifts)\n\nTechnology:\n- Prisma models (Shift, ShiftSignup with status enum)\n- TEMP user creation (automatic expiry)\n- Email templates for confirmations\n

Migration Impact: V1 shifts migrate. Signups extracted to separate table. Status defaults to CONFIRMED.

"},{"location":"v2/migration/feature-parity/#5-canvassing-system-new-in-v2","title":"5. Canvassing System (New in V2)","text":""},{"location":"v2/migration/feature-parity/#v2-features","title":"V2 Features","text":"
Complete canvassing workflow:\n- Start/end canvass session (track volunteer time)\n- GPS tracking (real-time trail recording, 30-day retention)\n- Walking route algorithm (nearest-neighbor with haversine distance)\n- Visit recording (outcome, support level, notes, rate-limited 30/min)\n- Visit outcomes:\n  - CONTACT_MADE\n  - NOT_HOME\n  - REFUSED\n  - MOVED\n  - DECEASED\n  - WRONG_ADDRESS\n- Admin dashboard:\n  - Active sessions\n  - Total visits (today, week)\n  - Activity feed (recent visits)\n  - Cut progress (locations visited vs total)\n  - Leaderboard (top volunteers by visits, period filter)\n- Volunteer portal:\n  - Full-screen canvass map\n  - GPS position tracking\n  - Walking route display\n  - Bottom sheet visit recording\n  - Activity history (my visits)\n  - Route history (past sessions)\n\nTechnology:\n- Prisma models (CanvassSession, CanvassVisit, TrackingSession, TrackPoint)\n- React Leaflet map with custom controls\n- Zustand canvass store (client state)\n- Abandoned session cleanup (hourly, ACTIVE > 12h \u2192 ABANDONED)\n- Stale tracking cleanup (no data for 2h)\n

Migration Impact: New feature, no V1 equivalent.

"},{"location":"v2/migration/feature-parity/#6-landing-page-builder-new-in-v2","title":"6. Landing Page Builder (New in V2)","text":""},{"location":"v2/migration/feature-parity/#v2-features_1","title":"V2 Features","text":"
Visual page builder:\n- GrapesJS WYSIWYG editor\n- Drag-and-drop block placement\n- Custom block library (Hero, Features, CTA, etc.)\n- Live preview\n- Desktop-only editor (mobile warning)\n- Save hotkey (Ctrl+S)\n\nPage management:\n- CRUD operations (create, edit, delete, publish)\n- Slug-based routing (/p/:slug)\n- Public rendering\n- MkDocs export (Jinja2 Material theme overrides)\n- Export formats: themed (with header/footer) or standalone\n\nTechnology:\n- GrapesJS 0.21+\n- Prisma models (LandingPage, PageBlock)\n- React admin UI\n- MkDocs integration (override templates)\n

Migration Impact: New feature, no V1 equivalent.

"},{"location":"v2/migration/feature-parity/#7-email-templates-new-in-v2","title":"7. Email Templates (New in V2)","text":""},{"location":"v2/migration/feature-parity/#v2-features_2","title":"V2 Features","text":"
Template management:\n- Create templates (HTML + plain text)\n- Template categories (campaign, shift, response, system)\n- Variable substitution ({{campaignTitle}}, {{userName}}, etc.)\n- Version control (publish creates new version)\n- Live preview\n- Test email sending\n\nAdmin features:\n- Template library\n- Version history\n- Rollback to previous version\n- Duplicate template\n- Delete template (soft delete)\n\nTechnology:\n- Prisma models (EmailTemplate, EmailTemplateVersion)\n- Handlebars-style variable syntax\n- HTML + plain text variants\n

Migration Impact: New feature, no V1 equivalent.

"},{"location":"v2/migration/feature-parity/#8-media-library-new-in-v2","title":"8. Media Library (New in V2)","text":""},{"location":"v2/migration/feature-parity/#v2-features_3","title":"V2 Features","text":"
Video management:\n- Upload videos (MP4, MOV, AVI, MKV, WebM, M4V, FLV up to 10GB)\n- Automatic metadata extraction (FFprobe):\n  - Duration, dimensions, orientation\n  - Video quality (resolution-based)\n  - Audio track detection\n- Bulk operations (delete, lock/unlock)\n- Categories (assign to shared gallery)\n- Lock/unlock system (public visibility control)\n\nPublic gallery:\n- Category-based filtering\n- Video detail page\n- Reactions (6 emoji types: like, love, laugh, wow, sad, angry)\n- Comment system (future)\n\nTechnology:\n- Fastify microservice (port 4100)\n- Drizzle ORM (separate from Prisma)\n- FFprobe metadata extraction (30s timeout)\n- Dual API architecture (media separate from main API)\n

Migration Impact: New feature, no V1 equivalent.

"},{"location":"v2/migration/feature-parity/#9-monitoring-stack-new-in-v2","title":"9. Monitoring Stack (New in V2)","text":""},{"location":"v2/migration/feature-parity/#v2-features_4","title":"V2 Features","text":"
Metrics collection:\n- 12 custom cm_* metrics:\n  - cm_api_uptime_seconds\n  - cm_emails_sent_total\n  - cm_emails_failed_total\n  - cm_email_queue_size\n  - cm_email_send_duration_seconds\n  - cm_login_attempts_total\n  - cm_active_sessions\n  - cm_campaign_emails_total\n  - cm_response_submissions_total\n  - cm_canvass_visits_total\n  - cm_active_canvass_sessions\n  - cm_shift_signups_total\n  - cm_external_service_up\n\nDashboards:\n- System Health (CPU, memory, disk, network)\n- Application Overview (API requests, errors, response times)\n- API Performance (endpoint latency, throughput)\n\nAlerts:\n- High error rate (> 5% for 5min)\n- API down\n- High email queue size (> 1000)\n- External service down (NocoDB, Redis, PostgreSQL)\n\nTechnology:\n- Prometheus (metrics collection, 15s scrape)\n- Grafana (visualization, 3 dashboards)\n- Alertmanager (alert routing)\n- Gotify (notification delivery, optional)\n- cAdvisor (container metrics)\n- Node Exporter (host metrics)\n- Redis Exporter (Redis metrics)\n

Migration Impact: New feature, no V1 equivalent. Enable with --profile monitoring.

"},{"location":"v2/migration/feature-parity/#feature-status-summary","title":"Feature Status Summary","text":""},{"location":"v2/migration/feature-parity/#v1-features-in-v2","title":"V1 Features in V2","text":"Feature V2 Status Implementation Campaigns \u2705 Complete Enhanced with highlighting, response wall toggle Representative Lookup \u2705 Complete Enhanced with caching, stats Response Wall \u2705 Complete Enhanced with moderation, upvoting Locations \u2705 Complete Enhanced with structured address, multi-provider geocoding Shifts \u2705 Complete Enhanced with cut assignment, status tracking Public Shift Signup \u2705 Complete Same functionality, improved UX User Management \u2705 Complete Enhanced with unified model, RBAC Email Sending \u2705 Complete Enhanced with BullMQ, monitoring CSV Import/Export \u2705 Complete Enhanced with flexible mapping

Result: 100% V1 feature parity achieved

"},{"location":"v2/migration/feature-parity/#v1-features-not-in-v2","title":"V1 Features NOT in V2","text":"Feature Reason Alternative NocoDB as primary data layer Replaced by Prisma ORM NocoDB available as read-only browser Session-based authentication Replaced by JWT More scalable, stateless auth Separate apps (influence, map) Unified into single API Better code reuse, consistency"},{"location":"v2/migration/feature-parity/#v2-only-features","title":"V2-Only Features","text":"Feature Status Phase Landing Page Builder \u2705 Complete Phase 12 Email Templates \u2705 Complete Phase 12 Media Library \u2705 Complete Phase 12 Canvassing System \u2705 Complete Phase 13 GPS Tracking \u2705 Complete Phase 13 Walk Sheets \u2705 Complete Phase 10 NAR Import \u2705 Complete Phase 14 Data Quality Dashboard \u2705 Complete Phase 14 Monitoring Stack \u2705 Complete Phase 14 Pangolin Tunnel \u2705 Complete Phase 14 Observability Dashboard \u2705 Complete Phase 14"},{"location":"v2/migration/feature-parity/#migration-priority","title":"Migration Priority","text":"

When migrating from V1 to V2, prioritize features in this order:

"},{"location":"v2/migration/feature-parity/#1-critical-must-migrate-first","title":"1. Critical (Must Migrate First)","text":""},{"location":"v2/migration/feature-parity/#2-high-priority-migrate-early","title":"2. High Priority (Migrate Early)","text":""},{"location":"v2/migration/feature-parity/#3-medium-priority-migrate-mid-phase","title":"3. Medium Priority (Migrate Mid-Phase)","text":""},{"location":"v2/migration/feature-parity/#4-low-priority-migrate-later","title":"4. Low Priority (Migrate Later)","text":""},{"location":"v2/migration/feature-parity/#5-optional-new-v2-features","title":"5. Optional (New V2 Features)","text":""},{"location":"v2/migration/feature-parity/#workarounds-for-missing-features","title":"Workarounds for Missing Features","text":"

If you need a V1 feature not yet migrated:

"},{"location":"v2/migration/feature-parity/#1-run-v1-and-v2-in-parallel","title":"1. Run V1 and V2 in Parallel","text":"
# Keep V1 running for specific features\ndocker compose -f docker-compose.v1.yml up -d\n\n# Run V2 for new features\ndocker compose up -d\n\n# Use reverse proxy to route by path:\n# /v1/* \u2192 V1 apps\n# /v2/* \u2192 V2 API\n
"},{"location":"v2/migration/feature-parity/#2-manual-data-entry","title":"2. Manual Data Entry","text":"

For small datasets, manually re-enter data in V2 admin:

"},{"location":"v2/migration/feature-parity/#3-custom-migration-scripts","title":"3. Custom Migration Scripts","text":"

For unique V1 customizations, write custom transformation scripts:

// scripts/migrate-custom-fields.js\nconst customFieldMapping = {\n  v1Field: 'v2Field',\n  // Add your mappings\n};\n\n// Transform and import\n
"},{"location":"v2/migration/feature-parity/#future-roadmap","title":"Future Roadmap","text":""},{"location":"v2/migration/feature-parity/#planned-for-v2-phase-15","title":"Planned for V2 Phase 15+","text":""},{"location":"v2/migration/feature-parity/#community-feature-requests","title":"Community Feature Requests","text":"

Vote on features at: https://github.com/changemaker-lite/v2/discussions

"},{"location":"v2/migration/feature-parity/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/migration/feature-parity/#next-steps","title":"Next Steps","text":"
  1. Review feature matrix - Identify features you use
  2. Prioritize migration - Critical features first
  3. Test on staging - Verify feature parity
  4. Provide feedback - Report missing features
  5. Plan new feature adoption - Landing pages, canvassing, etc.

Feature Parity Achieved

V2 provides 100% V1 feature parity plus significant new capabilities. No functionality will be lost in migration.

"},{"location":"v2/troubleshooting/","title":"Troubleshooting Guide","text":"

This section covers common issues, error messages, and solutions for Changemaker Lite V2. Use this guide to diagnose and resolve problems with installation, configuration, and operation.

"},{"location":"v2/troubleshooting/#quick-reference","title":"Quick Reference","text":""},{"location":"v2/troubleshooting/#common-errors","title":"Common Errors","text":"

Frequently encountered error messages:

"},{"location":"v2/troubleshooting/#faq","title":"FAQ","text":"

Frequently asked questions:

"},{"location":"v2/troubleshooting/#docker-issues","title":"Docker Issues","text":"

Container and orchestration problems:

"},{"location":"v2/troubleshooting/#database-issues","title":"Database Issues","text":"

PostgreSQL and Prisma problems:

"},{"location":"v2/troubleshooting/#authentication-issues","title":"Authentication Issues","text":"

Login and permission problems:

"},{"location":"v2/troubleshooting/#email-issues","title":"Email Issues","text":"

Email delivery problems:

"},{"location":"v2/troubleshooting/#geocoding-issues","title":"Geocoding Issues","text":"

Address geocoding problems:

"},{"location":"v2/troubleshooting/#monitoring-issues","title":"Monitoring Issues","text":"

Observability and metrics problems:

"},{"location":"v2/troubleshooting/#performance-optimization","title":"Performance Optimization","text":"

Speed and efficiency improvements:

"},{"location":"v2/troubleshooting/#common-issues","title":"Common Issues","text":""},{"location":"v2/troubleshooting/#installation-problems","title":"Installation Problems","text":"

Symptom: Docker containers fail to start

Common Causes: - Port conflicts - Missing environment variables - Insufficient resources - Corrupted volumes

Solutions: 1. Check port availability: netstat -tulpn | grep <port> 2. Verify .env file exists and is complete 3. Increase Docker memory/CPU limits 4. Remove volumes: docker compose down -v

Symptom: Database migration fails

Common Causes: - Database not running - Connection string incorrect - Migration conflict - Permission issues

Solutions: 1. Verify PostgreSQL is running: docker compose ps 2. Check DATABASE_URL in .env 3. Reset database (dev only): npx prisma migrate reset 4. Check user permissions

Symptom: \"Cannot connect to Redis\"

Common Causes: - Redis not started - Wrong password - Port conflict - Network issue

Solutions: 1. Start Redis: docker compose up -d redis 2. Verify REDIS_PASSWORD matches in all services 3. Check port 6379 not in use 4. Test connection: docker compose exec redis redis-cli ping

"},{"location":"v2/troubleshooting/#runtime-problems","title":"Runtime Problems","text":"

Symptom: API returns 500 errors

Common Causes: - Unhandled exception - Database query error - Service unavailable - Configuration issue

Solutions: 1. Check API logs: docker compose logs -f api 2. Review error stack trace 3. Test database connection 4. Verify environment variables

Symptom: Frontend shows blank page

Common Causes: - Build error - API not reachable - CORS issue - JavaScript error

Solutions: 1. Check browser console (F12) 2. Verify VITE_API_URL in .env 3. Check nginx CORS headers 4. Rebuild admin: docker compose build admin

Symptom: Emails not sending

Common Causes: - SMTP credentials wrong - Test mode enabled - Queue worker not running - Network blocked

Solutions: 1. Check EMAIL_TEST_MODE setting 2. Verify SMTP settings in .env 3. Check email queue: docker compose logs -f api | grep email 4. Test with MailHog (port 8025)

"},{"location":"v2/troubleshooting/#configuration-issues","title":"Configuration Issues","text":"

Symptom: Subdomain routing not working

Common Causes: - Nginx config error - DNS not set up - Tunnel not configured - Certificate issue

Solutions: 1. Check nginx config: docker compose exec nginx nginx -t 2. Verify DNS records 3. Review tunnel status in Pangolin page 4. Check SSL certificate validity

Symptom: Feature not working (media, listmonk, etc.)

Common Causes: - Feature flag disabled - Service not started - API credentials missing - Integration not configured

Solutions: 1. Check feature flag in .env (e.g., ENABLE_MEDIA_FEATURES) 2. Start required services: docker compose up -d <service> 3. Verify API keys/credentials 4. Complete setup wizard in admin

"},{"location":"v2/troubleshooting/#diagnostic-commands","title":"Diagnostic Commands","text":""},{"location":"v2/troubleshooting/#check-service-status","title":"Check Service Status","text":"
# All services\ndocker compose ps\n\n# Specific service\ndocker compose ps api\n\n# Service logs\ndocker compose logs -f api\ndocker compose logs --tail=100 v2-postgres\n
"},{"location":"v2/troubleshooting/#test-connectivity","title":"Test Connectivity","text":"
# API health check\ncurl http://localhost:4000/health\n\n# Database connection\ndocker compose exec api npx prisma db execute --stdin <<< \"SELECT 1\"\n\n# Redis connection\ndocker compose exec redis redis-cli ping\n
"},{"location":"v2/troubleshooting/#database-diagnostics","title":"Database Diagnostics","text":"
# Open Prisma Studio\ncd api && npx prisma studio\n\n# Check migrations\ncd api && npx prisma migrate status\n\n# View database\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2\n
"},{"location":"v2/troubleshooting/#view-logs","title":"View Logs","text":"
# All services\ndocker compose logs -f\n\n# Specific service\ndocker compose logs -f api\ndocker compose logs -f admin\n\n# Error logs only\ndocker compose logs -f | grep ERROR\n
"},{"location":"v2/troubleshooting/#resource-usage","title":"Resource Usage","text":"
# Docker stats\ndocker stats\n\n# Disk usage\ndocker system df\n\n# Container resource limits\ndocker compose config | grep mem_limit\n
"},{"location":"v2/troubleshooting/#error-message-reference","title":"Error Message Reference","text":""},{"location":"v2/troubleshooting/#database-errors","title":"Database Errors","text":"

P2002: Unique constraint failed\n
Cause: Duplicate value for unique field (email, slug, etc.) Fix: Use different value or update existing record

P2025: Record not found\n
Cause: Trying to access non-existent record Fix: Verify ID exists, check deletion

P2021: Table does not exist\n
Cause: Missing migration Fix: Run npx prisma migrate deploy

"},{"location":"v2/troubleshooting/#api-errors","title":"API Errors","text":"

401 Unauthorized\n
Cause: Missing/invalid JWT token Fix: Login again, check token expiration

403 Forbidden\n
Cause: Insufficient permissions Fix: Check user role, verify RBAC middleware

429 Too Many Requests\n
Cause: Rate limit exceeded Fix: Wait, reduce request frequency

"},{"location":"v2/troubleshooting/#docker-errors","title":"Docker Errors","text":"

port is already allocated\n
Cause: Port conflict Fix: Stop conflicting service, change port in docker-compose.yml

no space left on device\n
Cause: Disk full Fix: Clean up: docker system prune -a

network not found\n
Cause: Docker network missing Fix: Recreate: docker compose down && docker compose up -d

"},{"location":"v2/troubleshooting/#when-to-get-help","title":"When to Get Help","text":"

Escalate to GitHub issues if:

"},{"location":"v2/troubleshooting/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/auth-issues/","title":"Authentication and Authorization Issues","text":"

This guide covers authentication (who you are) and authorization (what you can do) problems in Changemaker Lite V2.

"},{"location":"v2/troubleshooting/auth-issues/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/auth-issues/#authentication-system","title":"Authentication System","text":"

Changemaker Lite V2 uses JWT-based authentication:

"},{"location":"v2/troubleshooting/auth-issues/#authorization-system","title":"Authorization System","text":"

Role-based access control (RBAC) with 5 roles:

Role Level Permissions SUPER_ADMIN 5 Full access to everything INFLUENCE_ADMIN 4 Manage campaigns, responses, email queue MAP_ADMIN 3 Manage locations, cuts, shifts, canvass USER 2 View public content, canvass (if assigned shift) TEMP 1 Very limited - shift signup confirmation only"},{"location":"v2/troubleshooting/auth-issues/#security-features","title":"Security Features","text":""},{"location":"v2/troubleshooting/auth-issues/#login-failures","title":"Login Failures","text":""},{"location":"v2/troubleshooting/auth-issues/#invalid-credentials","title":"Invalid Credentials","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/auth-issues/#symptoms","title":"Symptoms","text":"
{\n  \"error\": \"Unauthorized\",\n  \"message\": \"Invalid credentials\"\n}\n

Same message for: - User not found - Wrong password - User suspended

This is intentional (prevents user enumeration).

"},{"location":"v2/troubleshooting/auth-issues/#common-causes","title":"Common Causes","text":"
  1. Wrong password - Password incorrect
  2. User doesn't exist - Email not registered
  3. Typo in email - Email address wrong
  4. Account suspended - User marked as suspended
"},{"location":"v2/troubleshooting/auth-issues/#solutions","title":"Solutions","text":"

Solution 1: Verify user exists

# Check database\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT email, role FROM \\\"User\\\" WHERE email = 'user@example.com';\"\n\n# If no result, user doesn't exist\n

Solution 2: Reset password

# Generate bcrypt hash for new password\ndocker compose exec api node -e \"\nconst bcrypt = require('bcryptjs');\nconst hash = bcrypt.hashSync('NewPassword123!', 10);\nconsole.log(hash);\n\"\n\n# Update password\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET password = '\\$2a\\$10\\$...' WHERE email = 'user@example.com';\"\n

Solution 3: Create missing user

# Via API\ncurl -X POST http://localhost:4000/api/auth/register \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"email\": \"user@example.com\",\n    \"password\": \"SecurePass123!\",\n    \"name\": \"User Name\"\n  }'\n\n# Or via admin UI at /app/users\n

Solution 4: Check for suspended account

# Check user status\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT email, role, \\\"createdAt\\\" FROM \\\"User\\\" WHERE email = 'user@example.com';\"\n\n# If suspended field exists and is true, unsuspend:\n# (Note: V2 doesn't have suspended field yet, but may be added)\n

Solution 5: Check password requirements

Password must meet requirements: - 12+ characters - At least 1 uppercase letter - At least 1 lowercase letter - At least 1 digit

# Valid examples:\nSecurePass123!\nMyP@ssword99\nAdmin12345678\n
"},{"location":"v2/troubleshooting/auth-issues/#prevention","title":"Prevention","text":"

User Enumeration

The same error message for \"user not found\" and \"wrong password\" is intentional security behavior to prevent attackers from discovering valid email addresses.

"},{"location":"v2/troubleshooting/auth-issues/#account-suspended","title":"Account Suspended","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_1","title":"Symptoms","text":"
{\n  \"error\": \"Forbidden\",\n  \"message\": \"Account suspended\"\n}\n

User can't log in even with correct credentials.

"},{"location":"v2/troubleshooting/auth-issues/#common-causes_1","title":"Common Causes","text":"
  1. Manual suspension - Admin suspended account
  2. Security violation - Account flagged for suspicious activity
  3. Terms violation - Account suspended for policy violation
"},{"location":"v2/troubleshooting/auth-issues/#solutions_1","title":"Solutions","text":"

Solution 1: Check suspension status

# Check if user has suspended flag (if implemented)\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT email, role FROM \\\"User\\\" WHERE email = 'user@example.com';\"\n

Solution 2: Unsuspend account

# If suspension field exists:\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET suspended = false WHERE email = 'user@example.com';\"\n\n# Or delete user and recreate\n

Solution 3: Contact administrator

If you're a user: 1. Contact system administrator 2. Provide your email address 3. Wait for account review

"},{"location":"v2/troubleshooting/auth-issues/#prevention_1","title":"Prevention","text":"

V2 Status

V2 doesn't currently have a suspended field. This section is for future implementation or if added via custom migration.

"},{"location":"v2/troubleshooting/auth-issues/#email-not-verified","title":"Email Not Verified","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_2","title":"Symptoms","text":"
{\n  \"error\": \"Forbidden\",\n  \"message\": \"Email not verified. Please check your email for verification link.\"\n}\n
"},{"location":"v2/troubleshooting/auth-issues/#common-causes_2","title":"Common Causes","text":"
  1. Email not verified - User didn't click verification link
  2. Verification email not received - Email went to spam
  3. Verification link expired - Link older than 24 hours
"},{"location":"v2/troubleshooting/auth-issues/#solutions_2","title":"Solutions","text":"

Solution 1: Check verification status

# Check if emailVerified field exists\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT email, \\\"createdAt\\\" FROM \\\"User\\\" WHERE email = 'user@example.com';\"\n

Solution 2: Manually verify email

# Mark email as verified\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET \\\"emailVerified\\\" = true WHERE email = 'user@example.com';\"\n

Solution 3: Resend verification email

# Via API (if endpoint exists)\ncurl -X POST http://localhost:4000/api/auth/resend-verification \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\": \"user@example.com\"}'\n

Solution 4: Check spam folder

Verification emails may be marked as spam. Check: 1. Spam/Junk folder 2. Promotions tab (Gmail) 3. Email filters

"},{"location":"v2/troubleshooting/auth-issues/#prevention_2","title":"Prevention","text":"

V2 Status

V2 doesn't currently require email verification for login. This section is for future implementation.

"},{"location":"v2/troubleshooting/auth-issues/#token-issues","title":"Token Issues","text":""},{"location":"v2/troubleshooting/auth-issues/#token-expired","title":"Token Expired","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_3","title":"Symptoms","text":"
{\n  \"error\": \"Unauthorized\",\n  \"message\": \"Token expired\"\n}\n

Or:

Error: jwt expired\n
"},{"location":"v2/troubleshooting/auth-issues/#common-causes_3","title":"Common Causes","text":"
  1. Access token expired - Normal after 15 minutes inactive
  2. Refresh token expired - After 7 days
  3. System clock skew - Server/client time difference
"},{"location":"v2/troubleshooting/auth-issues/#solutions_3","title":"Solutions","text":"

Solution 1: Frontend auto-refresh

Frontend automatically refreshes tokens on 401. If this fails:

// Check refresh token in localStorage\nconst storage = JSON.parse(localStorage.getItem('auth-storage'));\nconsole.log('Has refresh token:', !!storage?.state?.refreshToken);\n\n// If missing, need to log in again\n

Solution 2: Manual refresh

# Refresh token via API\ncurl -X POST http://localhost:4000/api/auth/refresh \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"refreshToken\": \"YOUR_REFRESH_TOKEN\"}'\n\n# Returns new access + refresh tokens\n

Solution 3: Check token expiration

// Decode JWT to see expiration\nconst token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';\nconst payload = JSON.parse(atob(token.split('.')[1]));\nconsole.log('Expires:', new Date(payload.exp * 1000));\nconsole.log('Now:', new Date());\n

Solution 4: Check system time

# On server\ndocker compose exec api date\n\n# Should match actual time\n# If not, sync clock:\nsudo ntpdate -s time.nist.gov\n

Solution 5: Log in again

If refresh token also expired:

  1. You'll be redirected to login automatically
  2. Log in with email/password
  3. New tokens issued
"},{"location":"v2/troubleshooting/auth-issues/#prevention_3","title":"Prevention","text":""},{"location":"v2/troubleshooting/auth-issues/#invalid-token","title":"Invalid Token","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_4","title":"Symptoms","text":"
{\n  \"error\": \"Unauthorized\",\n  \"message\": \"Invalid token\"\n}\n

Or:

Error: jwt malformed\nError: invalid signature\nError: invalid algorithm\n
"},{"location":"v2/troubleshooting/auth-issues/#common-causes_4","title":"Common Causes","text":"
  1. Corrupted token - LocalStorage corruption
  2. Wrong secret - JWT_ACCESS_SECRET changed
  3. Tampered token - Someone modified the token
  4. Format error - Not a valid JWT
"},{"location":"v2/troubleshooting/auth-issues/#solutions_4","title":"Solutions","text":"

Solution 1: Verify JWT format

Valid JWT has 3 parts separated by dots:

const token = 'header.payload.signature';\nconsole.log(token.split('.').length); // Should be 3\n\n// Example valid token:\n// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.\n// eyJpZCI6IjEyMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInJvbGUiOiJVU0VSIiwiaWF0IjoxNzA4MDAwMDAwLCJleHAiOjE3MDgwMDA5MDB9.\n// signature-here\n

Solution 2: Clear localStorage and re-login

// In browser console\nlocalStorage.clear();\n// Then log in again\n

Solution 3: Verify JWT_ACCESS_SECRET

# Check API .env\ndocker compose exec api sh -c 'echo $JWT_ACCESS_SECRET'\n\n# Should be:\n# - At least 32 characters\n# - Never changed (changing invalidates all tokens)\n# - Different from JWT_REFRESH_SECRET\n

Solution 4: Test token verification

# Test token via API\ncurl http://localhost:4000/api/auth/me \\\n  -H \"Authorization: Bearer YOUR_ACCESS_TOKEN\"\n\n# If returns user, token is valid\n# If 401, token is invalid\n

Solution 5: Check API logs

# View token validation errors\ndocker compose logs api | grep -i \"jwt\\|token\"\n\n# Common errors:\n# - \"jwt malformed\" - not a valid JWT format\n# - \"invalid signature\" - wrong secret or tampered\n# - \"invalid algorithm\" - algorithm mismatch\n
"},{"location":"v2/troubleshooting/auth-issues/#prevention_4","title":"Prevention","text":""},{"location":"v2/troubleshooting/auth-issues/#token-not-found-in-header","title":"Token Not Found in Header","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_5","title":"Symptoms","text":"
{\n  \"error\": \"Unauthorized\",\n  \"message\": \"No token provided\"\n}\n

Or:

{\n  \"error\": \"Unauthorized\",\n  \"message\": \"Invalid authorization header format\"\n}\n
"},{"location":"v2/troubleshooting/auth-issues/#common-causes_5","title":"Common Causes","text":"
  1. Missing Authorization header - Header not sent
  2. Wrong header format - Not \"Bearer token\"
  3. Token not in localStorage - User not logged in
  4. API client misconfigured - axios interceptor not working
"},{"location":"v2/troubleshooting/auth-issues/#solutions_5","title":"Solutions","text":"

Solution 1: Check if logged in

// In browser console\nconst storage = JSON.parse(localStorage.getItem('auth-storage'));\nconsole.log('Access token:', storage?.state?.accessToken);\n\n// If null, not logged in\n

Solution 2: Verify header format

# Correct format\ncurl http://localhost:4000/api/users \\\n  -H \"Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"\n\n# Wrong formats:\n# -H \"Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"  # Missing \"Bearer\"\n# -H \"Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"       # Wrong header name\n# -H \"Authorization: Token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"  # Wrong prefix\n

Solution 3: Check axios interceptor

In admin/src/lib/api.ts:

// Request interceptor should add token\napi.interceptors.request.use((config) => {\n  const storage = JSON.parse(localStorage.getItem('auth-storage') || '{}');\n  const token = storage?.state?.accessToken;\n\n  if (token) {\n    config.headers.Authorization = `Bearer ${token}`;\n  }\n\n  return config;\n});\n

Solution 4: Test with curl

# Get token from localStorage (browser console)\nconst token = JSON.parse(localStorage.getItem('auth-storage')).state.accessToken;\nconsole.log(token);\n\n# Test with curl\ncurl http://localhost:4000/api/users \\\n  -H \"Authorization: Bearer [paste-token-here]\"\n

Solution 5: Log in again

# If all else fails, log out and log in\nlocalStorage.clear();\n# Navigate to /login\n
"},{"location":"v2/troubleshooting/auth-issues/#prevention_5","title":"Prevention","text":""},{"location":"v2/troubleshooting/auth-issues/#refresh-token-invalid","title":"Refresh Token Invalid","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_6","title":"Symptoms","text":"
{\n  \"error\": \"Unauthorized\",\n  \"message\": \"Invalid refresh token\"\n}\n

Auto-refresh fails, user logged out.

"},{"location":"v2/troubleshooting/auth-issues/#common-causes_6","title":"Common Causes","text":"
  1. Refresh token expired - Older than 7 days
  2. Token revoked - User logged out explicitly
  3. Token not in database - Database was reset
  4. Token rotation - Token already used (consumed)
"},{"location":"v2/troubleshooting/auth-issues/#solutions_6","title":"Solutions","text":"

Solution 1: Check refresh token in database

# Find refresh token\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, \\\"userId\\\", \\\"expiresAt\\\", \\\"createdAt\\\" FROM \\\"RefreshToken\\\"\n      WHERE token = 'YOUR_REFRESH_TOKEN_HASH';\"\n\n# If no result, token doesn't exist (revoked or expired)\n

Solution 2: Check expiration

# Find all refresh tokens for user\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, \\\"expiresAt\\\", \\\"createdAt\\\" FROM \\\"RefreshToken\\\"\n      WHERE \\\"userId\\\" = 'USER_UUID'\n      ORDER BY \\\"createdAt\\\" DESC;\"\n\n# Check if expiresAt < NOW()\n

Solution 3: Log in again

Refresh token can't be renewed. Must log in with email/password:

curl -X POST http://localhost:4000/api/auth/login \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"email\": \"user@example.com\",\n    \"password\": \"SecurePass123!\"\n  }'\n

Solution 4: Clear old refresh tokens

# Delete expired refresh tokens\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"DELETE FROM \\\"RefreshToken\\\" WHERE \\\"expiresAt\\\" < NOW();\"\n\n# Or delete all refresh tokens for user (logs out all devices)\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"DELETE FROM \\\"RefreshToken\\\" WHERE \\\"userId\\\" = 'USER_UUID';\"\n
"},{"location":"v2/troubleshooting/auth-issues/#prevention_6","title":"Prevention","text":""},{"location":"v2/troubleshooting/auth-issues/#permission-errors","title":"Permission Errors","text":""},{"location":"v2/troubleshooting/auth-issues/#insufficient-permissions","title":"Insufficient Permissions","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_7","title":"Symptoms","text":"
{\n  \"error\": \"Forbidden\",\n  \"message\": \"Insufficient permissions\"\n}\n

Or role-specific:

{\n  \"error\": \"Forbidden\",\n  \"message\": \"Requires one of: SUPER_ADMIN, MAP_ADMIN\"\n}\n
"},{"location":"v2/troubleshooting/auth-issues/#common-causes_7","title":"Common Causes","text":"
  1. Wrong role - User doesn't have required role
  2. TEMP user - Temporary user trying to access admin features
  3. Feature disabled - Feature flag not enabled
  4. Endpoint restricted - Endpoint requires specific role
"},{"location":"v2/troubleshooting/auth-issues/#solutions_7","title":"Solutions","text":"

Solution 1: Check user role

# View user role\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT email, role FROM \\\"User\\\" WHERE email = 'user@example.com';\"\n\n# Roles:\n# SUPER_ADMIN - full access\n# INFLUENCE_ADMIN - campaigns/responses\n# MAP_ADMIN - locations/cuts/shifts\n# USER - public content + canvass\n# TEMP - very limited\n

Solution 2: Update user role

# Via Prisma Studio (recommended)\ndocker compose exec api npx prisma studio\n# Navigate to User table, edit role field\n\n# Or via SQL\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET role = 'MAP_ADMIN' WHERE email = 'user@example.com';\"\n

Solution 3: Check endpoint requirements

In API code (api/src/modules/*/routes.ts):

// Example from campaigns.routes.ts\nrouter.post('/',\n  authenticate,  // Must be logged in\n  requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'),  // Must be admin\n  validate(createCampaignSchema),\n  campaignsController.create\n);\n

Solution 4: Verify feature flags

# Check .env for feature flags\ncat .env | grep ENABLE\n\n# Example:\nENABLE_MEDIA_FEATURES=true\nLISTMONK_SYNC_ENABLED=true\n

Solution 5: Check TEMP user restrictions

TEMP users are created during shift signup and have very limited permissions:

// TEMP users blocked by requireNonTemp middleware\nrouter.get('/my-data',\n  authenticate,\n  requireNonTemp,  // Blocks TEMP users\n  controller.getData\n);\n

To convert TEMP to USER:

docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET role = 'USER' WHERE email = 'temp@example.com';\"\n
"},{"location":"v2/troubleshooting/auth-issues/#prevention_7","title":"Prevention","text":""},{"location":"v2/troubleshooting/auth-issues/#role-restrictions","title":"Role Restrictions","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_8","title":"Symptoms","text":"

User logged in but can't access certain features.

"},{"location":"v2/troubleshooting/auth-issues/#common-causes_8","title":"Common Causes","text":"
  1. Not enough permissions - Role too low for feature
  2. Feature flag - Feature not enabled
  3. TEMP user - Temporary account with restrictions
"},{"location":"v2/troubleshooting/auth-issues/#solutions_8","title":"Solutions","text":"

Solution 1: View role permissions

Feature SUPER_ADMIN INFLUENCE_ADMIN MAP_ADMIN USER TEMP User management \u2705 \u274c \u274c \u274c \u274c Settings \u2705 \u274c \u274c \u274c \u274c Campaigns \u2705 \u2705 \u274c \u274c \u274c Responses \u2705 \u2705 \u274c \u274c \u274c Email queue \u2705 \u2705 \u274c \u274c \u274c Locations \u2705 \u274c \u2705 \u274c \u274c Cuts \u2705 \u274c \u2705 \u274c \u274c Shifts \u2705 \u274c \u2705 \u274c \u274c Canvass dashboard \u2705 \u274c \u2705 \u274c \u274c Public campaigns \u2705 \u2705 \u2705 \u2705 \u274c Public shifts \u2705 \u2705 \u2705 \u2705 \u2705 Volunteer canvass \u2705 \u2705 \u2705 \u2705 \u274c

Solution 2: Request role upgrade

If you need higher permissions: 1. Contact system administrator 2. Explain why you need the role 3. Wait for approval and role change

Solution 3: Create admin account

For first admin (if none exist):

# Connect to database\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2\n\n# Create SUPER_ADMIN\n# (Password must be pre-hashed with bcrypt)\nINSERT INTO \"User\" (id, email, password, name, role)\nVALUES (\n  gen_random_uuid(),\n  'admin@example.com',\n  '$2a$10$...',  -- bcrypt hash of 'Admin123!'\n  'System Admin',\n  'SUPER_ADMIN'\n);\n
"},{"location":"v2/troubleshooting/auth-issues/#prevention_8","title":"Prevention","text":""},{"location":"v2/troubleshooting/auth-issues/#session-issues","title":"Session Issues","text":""},{"location":"v2/troubleshooting/auth-issues/#session-timeout","title":"Session Timeout","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_9","title":"Symptoms","text":"

User inactive for a while, then gets logged out.

"},{"location":"v2/troubleshooting/auth-issues/#current-behavior","title":"Current Behavior","text":"

V2 uses JWT tokens (not sessions): - Access token expires after 15 minutes - Refresh token expires after 7 days - Auto-refresh on API calls extends session

"},{"location":"v2/troubleshooting/auth-issues/#solutions_9","title":"Solutions","text":"

Solution 1: Configure token expiration

In .env:

# Access token (default: 15m)\nJWT_ACCESS_EXPIRATION=30m\n\n# Refresh token (default: 7d)\nJWT_REFRESH_EXPIRATION=14d\n

Restart API:

docker compose restart api\n

Solution 2: Implement activity tracking

// In frontend, track last activity\nconst updateActivity = () => {\n  localStorage.setItem('lastActivity', Date.now().toString());\n};\n\n// On any user action\ndocument.addEventListener('click', updateActivity);\ndocument.addEventListener('keypress', updateActivity);\n\n// Check on load\nuseEffect(() => {\n  const lastActivity = parseInt(localStorage.getItem('lastActivity') || '0');\n  const now = Date.now();\n  const thirtyMinutes = 30 * 60 * 1000;\n\n  if (now - lastActivity > thirtyMinutes) {\n    // Log out due to inactivity\n    authStore.logout();\n  }\n}, []);\n
"},{"location":"v2/troubleshooting/auth-issues/#prevention_9","title":"Prevention","text":""},{"location":"v2/troubleshooting/auth-issues/#multiple-device-conflicts","title":"Multiple Device Conflicts","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_10","title":"Symptoms","text":"

User logged in on multiple devices, behavior inconsistent.

"},{"location":"v2/troubleshooting/auth-issues/#current-behavior_1","title":"Current Behavior","text":"

V2 supports multiple devices: - Each login creates new refresh token - All devices stay logged in independently - No device limit by default

"},{"location":"v2/troubleshooting/auth-issues/#solutions_10","title":"Solutions","text":"

Solution 1: View user's devices

# List all refresh tokens for user\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, \\\"createdAt\\\", \\\"expiresAt\\\" FROM \\\"RefreshToken\\\"\n      WHERE \\\"userId\\\" = 'USER_UUID'\n      ORDER BY \\\"createdAt\\\" DESC;\"\n\n# Each row = one device/session\n

Solution 2: Log out all devices

# Delete all refresh tokens for user\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"DELETE FROM \\\"RefreshToken\\\" WHERE \\\"userId\\\" = 'USER_UUID';\"\n\n# User must log in again on all devices\n

Solution 3: Log out specific device

# Delete specific refresh token\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"DELETE FROM \\\"RefreshToken\\\" WHERE id = 'REFRESH_TOKEN_UUID';\"\n

Solution 4: Implement device limit

In api/src/modules/auth/auth.service.ts:

// After creating refresh token, limit to 5 devices\nconst userTokens = await prisma.refreshToken.findMany({\n  where: { userId },\n  orderBy: { createdAt: 'desc' }\n});\n\n// Delete oldest tokens if > 5\nif (userTokens.length > 5) {\n  await prisma.refreshToken.deleteMany({\n    where: {\n      id: { in: userTokens.slice(5).map(t => t.id) }\n    }\n  });\n}\n
"},{"location":"v2/troubleshooting/auth-issues/#prevention_10","title":"Prevention","text":""},{"location":"v2/troubleshooting/auth-issues/#password-reset-issues","title":"Password Reset Issues","text":""},{"location":"v2/troubleshooting/auth-issues/#reset-link-expired","title":"Reset Link Expired","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_11","title":"Symptoms","text":"
{\n  \"error\": \"Bad Request\",\n  \"message\": \"Password reset link expired or invalid\"\n}\n
"},{"location":"v2/troubleshooting/auth-issues/#common-causes_9","title":"Common Causes","text":"
  1. Link expired - Older than 24 hours
  2. Already used - Link can only be used once
  3. Wrong token - Token doesn't match database
"},{"location":"v2/troubleshooting/auth-issues/#solutions_11","title":"Solutions","text":"

Solution 1: Request new reset link

# Via API (if endpoint exists)\ncurl -X POST http://localhost:4000/api/auth/forgot-password \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\": \"user@example.com\"}'\n

Solution 2: Manually reset password

# Generate bcrypt hash\ndocker compose exec api node -e \"\nconst bcrypt = require('bcryptjs');\nconsole.log(bcrypt.hashSync('NewPassword123!', 10));\n\"\n\n# Update password\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET password = '\\$2a\\$10\\$...' WHERE email = 'user@example.com';\"\n
"},{"location":"v2/troubleshooting/auth-issues/#prevention_11","title":"Prevention","text":"

V2 Status

V2 doesn't currently have password reset flow. This section is for future implementation.

"},{"location":"v2/troubleshooting/auth-issues/#email-not-received","title":"Email Not Received","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_12","title":"Symptoms","text":"

User requests password reset but doesn't receive email.

"},{"location":"v2/troubleshooting/auth-issues/#common-causes_10","title":"Common Causes","text":"
  1. Email in spam - Filtered to spam folder
  2. SMTP issue - Email sending failed
  3. Wrong email - Typo in email address
  4. Email delay - Taking long to deliver
"},{"location":"v2/troubleshooting/auth-issues/#solutions_12","title":"Solutions","text":"

Solution 1: Check spam folder

  1. Check Spam/Junk folder
  2. Check Promotions tab (Gmail)
  3. Check email filters

Solution 2: Check email logs

# API logs show email sending\ndocker compose logs api | grep -i \"email\\|smtp\"\n\n# Should show:\n# Email sent to user@example.com: Password Reset\n

Solution 3: Check MailHog (dev mode)

If EMAIL_TEST_MODE=true:

# Open MailHog\nhttp://localhost:8025\n\n# All emails appear here instead of being sent\n

Solution 4: Test SMTP connection

# Test SMTP via API\ncurl -X POST http://localhost:4000/api/test-email \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\n    \"to\": \"test@example.com\",\n    \"subject\": \"Test\",\n    \"text\": \"Test email\"\n  }'\n

Solution 5: Manually reset password

See \"Manually reset password\" in previous section.

"},{"location":"v2/troubleshooting/auth-issues/#prevention_12","title":"Prevention","text":""},{"location":"v2/troubleshooting/auth-issues/#rate-limiting","title":"Rate Limiting","text":""},{"location":"v2/troubleshooting/auth-issues/#too-many-login-attempts","title":"Too Many Login Attempts","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_13","title":"Symptoms","text":"
{\n  \"error\": \"Too Many Requests\",\n  \"message\": \"Too many login attempts. Please try again later.\"\n}\n
"},{"location":"v2/troubleshooting/auth-issues/#common-causes_11","title":"Common Causes","text":"
  1. Too many failed logins - More than 10/minute
  2. Automated attack - Bot trying to brute-force
  3. Shared IP - Multiple users behind same NAT
"},{"location":"v2/troubleshooting/auth-issues/#solutions_13","title":"Solutions","text":"

Solution 1: Wait and retry

Rate limit is per IP address: - Limit: 10 requests per minute - Window: 1 minute - Action: Wait 1 minute, then try again

Solution 2: Check Redis rate limit

# Connect to Redis\ndocker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD\n\n# Find rate limit keys\nKEYS rl:auth:*\n\n# Check specific IP\nGET rl:auth:192.168.1.100\n\n# Shows number of requests in current window\n

Solution 3: Clear rate limit (admin)

# Delete rate limit key for IP\ndocker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD DEL rl:auth:192.168.1.100\n\n# Or clear all auth rate limits\ndocker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD --scan --pattern \"rl:auth:*\" | xargs docker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD DEL\n

Solution 4: Adjust rate limit

In api/src/middleware/rate-limit.ts:

export const authRateLimit = rateLimit({\n  windowMs: 60 * 1000,  // 1 minute\n  max: 10,  // 10 requests per minute\n  message: 'Too many login attempts. Please try again later.',\n  standardHeaders: true,\n  legacyHeaders: false,\n});\n\n// Increase to 20/minute:\nmax: 20,\n

Solution 5: Use different IP

If behind NAT with many users: 1. Use VPN 2. Use mobile network 3. Contact administrator to whitelist IP

"},{"location":"v2/troubleshooting/auth-issues/#prevention_13","title":"Prevention","text":""},{"location":"v2/troubleshooting/auth-issues/#account-temporarily-locked","title":"Account Temporarily Locked","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_14","title":"Symptoms","text":"
{\n  \"error\": \"Forbidden\",\n  \"message\": \"Account temporarily locked due to too many failed login attempts. Please try again in 30 minutes.\"\n}\n
"},{"location":"v2/troubleshooting/auth-issues/#solutions_14","title":"Solutions","text":"

Solution 1: Wait for unlock

Accounts auto-unlock after lockout period (default: 30 minutes).

Solution 2: Manually unlock (admin)

# If lockout implemented in database:\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET \\\"lockedUntil\\\" = NULL WHERE email = 'user@example.com';\"\n

Solution 3: Contact administrator

If you're a user: 1. Contact system administrator 2. Verify your identity 3. Request account unlock

"},{"location":"v2/troubleshooting/auth-issues/#prevention_14","title":"Prevention","text":"

V2 Status

V2 doesn't currently have account lockout. This section is for future implementation.

"},{"location":"v2/troubleshooting/auth-issues/#debugging-auth","title":"Debugging Auth","text":""},{"location":"v2/troubleshooting/auth-issues/#checking-jwt-payload","title":"Checking JWT Payload","text":"

Severity: \ud83d\udfe2 Low (informational)

"},{"location":"v2/troubleshooting/auth-issues/#how-to-decode-jwt","title":"How to Decode JWT","text":"
// In browser console\nconst token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInJvbGUiOiJVU0VSIiwiaWF0IjoxNzA4MDAwMDAwLCJleHAiOjE3MDgwMDA5MDB9.signature';\n\n// Decode header\nconst header = JSON.parse(atob(token.split('.')[0]));\nconsole.log('Header:', header);\n// { alg: 'HS256', typ: 'JWT' }\n\n// Decode payload\nconst payload = JSON.parse(atob(token.split('.')[1]));\nconsole.log('Payload:', payload);\n// {\n//   id: '123',\n//   email: 'test@example.com',\n//   role: 'USER',\n//   iat: 1708000000,  // Issued at (Unix timestamp)\n//   exp: 1708000900   // Expires at (Unix timestamp)\n// }\n\n// Check expiration\nconsole.log('Issued:', new Date(payload.iat * 1000));\nconsole.log('Expires:', new Date(payload.exp * 1000));\nconsole.log('Is expired:', Date.now() > payload.exp * 1000);\n
"},{"location":"v2/troubleshooting/auth-issues/#verifying-refresh-tokens","title":"Verifying Refresh Tokens","text":""},{"location":"v2/troubleshooting/auth-issues/#check-refresh-token-in-database","title":"Check Refresh Token in Database","text":"
# Find all refresh tokens for user\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT rt.id, rt.\\\"createdAt\\\", rt.\\\"expiresAt\\\", u.email\n      FROM \\\"RefreshToken\\\" rt\n      JOIN \\\"User\\\" u ON rt.\\\"userId\\\" = u.id\n      WHERE u.email = 'user@example.com'\n      ORDER BY rt.\\\"createdAt\\\" DESC;\"\n\n# Output:\n# id                                   | createdAt            | expiresAt            | email\n# uuid-here                            | 2026-02-10 10:00:00 | 2026-02-17 10:00:00 | user@example.com\n\n# Check if expired:\n# SELECT id FROM \"RefreshToken\" WHERE id = 'uuid' AND \"expiresAt\" > NOW();\n
"},{"location":"v2/troubleshooting/auth-issues/#checking-user-status","title":"Checking User Status","text":""},{"location":"v2/troubleshooting/auth-issues/#view-user-details","title":"View User Details","text":"
# Full user details\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, email, name, role, \\\"createdAt\\\", \\\"updatedAt\\\"\n      FROM \\\"User\\\"\n      WHERE email = 'user@example.com';\"\n\n# Check active sessions\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT COUNT(*) as active_sessions\n      FROM \\\"RefreshToken\\\" rt\n      JOIN \\\"User\\\" u ON rt.\\\"userId\\\" = u.id\n      WHERE u.email = 'user@example.com'\n        AND rt.\\\"expiresAt\\\" > NOW();\"\n
"},{"location":"v2/troubleshooting/auth-issues/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/auth-issues/#authentication-documentation","title":"Authentication Documentation","text":""},{"location":"v2/troubleshooting/auth-issues/#other-troubleshooting","title":"Other Troubleshooting","text":""},{"location":"v2/troubleshooting/auth-issues/#security-resources","title":"Security Resources","text":"

Last Updated: February 2026 Version: V2.0 Status: Complete

"},{"location":"v2/troubleshooting/common-errors/","title":"Common Errors and Solutions","text":"

This guide covers the most frequently encountered errors in Changemaker Lite V2 and their solutions.

"},{"location":"v2/troubleshooting/common-errors/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/common-errors/#how-to-use-this-guide","title":"How to Use This Guide","text":"
  1. Find your error - Use the error code or message to locate the section
  2. Diagnose - Read the symptoms and causes
  3. Apply solution - Follow step-by-step instructions
  4. Prevent recurrence - Implement preventive measures
"},{"location":"v2/troubleshooting/common-errors/#error-severity-levels","title":"Error Severity Levels","text":"Level Icon Meaning Action Critical \ud83d\udd34 System down or data at risk Fix immediately High \ud83d\udfe0 Feature unavailable Fix within hours Medium \ud83d\udfe1 Degraded performance Fix within days Low \ud83d\udfe2 Minor inconvenience Fix when convenient"},{"location":"v2/troubleshooting/common-errors/#quick-error-lookup","title":"Quick Error Lookup","text":"Error Code Category Page 401 Authentication Link 403 Authorization Link 404 Not Found Link 422 Validation Link 500 Server Error Link CORS Frontend Link ECONNREFUSED Database Link"},{"location":"v2/troubleshooting/common-errors/#authentication-errors","title":"Authentication Errors","text":""},{"location":"v2/troubleshooting/common-errors/#401-unauthorized","title":"401 Unauthorized","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/common-errors/#symptoms","title":"Symptoms","text":"
{\n  \"error\": \"Unauthorized\",\n  \"message\": \"Invalid or missing token\"\n}\n

Browser console:

Error: Request failed with status code 401\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes","title":"Common Causes","text":"
  1. Missing token - No Authorization header sent
  2. Expired token - Access token older than 15 minutes
  3. Invalid token - Corrupted or tampered token
  4. Wrong environment - Token from dev used in production
"},{"location":"v2/troubleshooting/common-errors/#solutions","title":"Solutions","text":"

Solution 1: Check if logged in

// In browser console\nconsole.log(localStorage.getItem('auth-storage'));\n

If null or missing accessToken, you need to log in again.

Solution 2: Refresh token

The frontend automatically refreshes tokens. If this fails:

  1. Log out completely
  2. Clear localStorage: localStorage.clear()
  3. Log in again

Solution 3: Verify API configuration

Check admin/.env:

VITE_API_URL=http://localhost:4000  # Must match actual API URL\n

Solution 4: Check token expiration

// In browser console\nconst storage = JSON.parse(localStorage.getItem('auth-storage'));\nconst payload = JSON.parse(atob(storage.state.accessToken.split('.')[1]));\nconsole.log('Token expires:', new Date(payload.exp * 1000));\nconsole.log('Current time:', new Date());\n

If expired, the refresh interceptor should handle this. If not working:

# Check API logs\ndocker compose logs api | grep \"refresh\"\n
"},{"location":"v2/troubleshooting/common-errors/#prevention","title":"Prevention","text":"

Security Note

401 errors may return generic messages to prevent user enumeration. This is intentional security behavior.

"},{"location":"v2/troubleshooting/common-errors/#403-forbidden","title":"403 Forbidden","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/common-errors/#symptoms_1","title":"Symptoms","text":"
{\n  \"error\": \"Forbidden\",\n  \"message\": \"Insufficient permissions\"\n}\n

Or role-specific:

{\n  \"error\": \"Forbidden\",\n  \"message\": \"Requires one of: SUPER_ADMIN, MAP_ADMIN\"\n}\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_1","title":"Common Causes","text":"
  1. Wrong role - User lacks required role
  2. TEMP user - Temporary users restricted from most features
  3. Feature disabled - Feature flag not enabled
  4. Wrong endpoint - Using admin endpoint as public user
"},{"location":"v2/troubleshooting/common-errors/#solutions_1","title":"Solutions","text":"

Solution 1: Check user role

# In database\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT email, role FROM \\\"User\\\" WHERE email = 'your@email.com';\"\n

Solution 2: Update user role

-- Via Prisma Studio (recommended)\ndocker compose exec api npx prisma studio\n-- Navigate to User table, edit role\n\n-- Or via SQL\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET role = 'MAP_ADMIN' WHERE email = 'your@email.com';\"\n

Solution 3: Check feature flags

# In API logs\ndocker compose logs api | grep \"ENABLE_\"\n\n# Check .env\ncat .env | grep ENABLE\n

Solution 4: Verify endpoint permissions

Check api/src/modules/*/routes.ts:

// Admin endpoint\nrouter.post('/', authenticate, requireRole('SUPER_ADMIN'), ...);\n\n// Public endpoint (no auth)\nrouter.get('/public', ...);\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_1","title":"Prevention","text":""},{"location":"v2/troubleshooting/common-errors/#invalid-token","title":"Invalid Token","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/common-errors/#symptoms_2","title":"Symptoms","text":"
{\n  \"error\": \"Unauthorized\",\n  \"message\": \"Invalid token\"\n}\n

Or in API logs:

Error: jwt malformed\nError: invalid signature\nError: jwt must be provided\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_2","title":"Common Causes","text":"
  1. Corrupted token - LocalStorage corruption
  2. Wrong secret - JWT_ACCESS_SECRET changed
  3. Modified token - Attempted tampering
  4. Format error - Not a valid JWT structure
"},{"location":"v2/troubleshooting/common-errors/#solutions_2","title":"Solutions","text":"

Solution 1: Clear and re-login

// In browser console\nlocalStorage.clear();\n// Then log in again\n

Solution 2: Verify JWT structure

Valid JWT has 3 parts separated by dots:

const token = 'header.payload.signature';\nconsole.log(token.split('.').length); // Should be 3\n

Solution 3: Check secret configuration

# In .env\nJWT_ACCESS_SECRET=your-secret-here-32-chars-min\nJWT_REFRESH_SECRET=different-secret-here-32-chars-min\n\n# Secrets must:\n# - Be different from each other\n# - Be at least 32 characters\n# - Remain unchanged (changing invalidates all tokens)\n

Solution 4: Verify token in logs

# API logs show token validation errors\ndocker compose logs api | tail -100 | grep \"jwt\"\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_2","title":"Prevention","text":""},{"location":"v2/troubleshooting/common-errors/#token-expired","title":"Token Expired","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_3","title":"Symptoms","text":"
{\n  \"error\": \"Unauthorized\",\n  \"message\": \"Token expired\"\n}\n

Or:

Error: jwt expired\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_3","title":"Common Causes","text":"
  1. Access token expired - Normal after 15 minutes of inactivity
  2. Refresh token expired - Refresh token older than 7 days
  3. System clock skew - Server/client time mismatch
  4. Refresh failed - Refresh token invalid or revoked
"},{"location":"v2/troubleshooting/common-errors/#solutions_3","title":"Solutions","text":"

Solution 1: Automatic refresh

Frontend automatically refreshes tokens on 401. If this fails:

// Check refresh token in localStorage\nconst storage = JSON.parse(localStorage.getItem('auth-storage'));\nconsole.log('Has refresh token:', !!storage?.state?.refreshToken);\n

Solution 2: Manual login

If refresh token expired (after 7 days):

  1. You'll be redirected to login automatically
  2. Log in with email/password
  3. New tokens issued

Solution 3: Check system time

# On server\ndate\n\n# Sync if incorrect\nsudo ntpdate -s time.nist.gov\n

Solution 4: Verify token expiration

# In API logs\ndocker compose logs api | grep \"expired\"\n\n# Check token age\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT email, \\\"createdAt\\\", \\\"expiresAt\\\" FROM \\\"RefreshToken\\\" ORDER BY \\\"createdAt\\\" DESC LIMIT 10;\"\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_3","title":"Prevention","text":"

Developer Tip

During development, use longer token expiration in .env:

JWT_ACCESS_EXPIRATION=1d  # Instead of 15m\nJWT_REFRESH_EXPIRATION=30d  # Instead of 7d\n

"},{"location":"v2/troubleshooting/common-errors/#user-not-found","title":"User Not Found","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_4","title":"Symptoms","text":"
{\n  \"error\": \"Unauthorized\",\n  \"message\": \"Invalid credentials\"\n}\n

Note: Same message for both \"user not found\" and \"wrong password\" (security feature).

"},{"location":"v2/troubleshooting/common-errors/#common-causes_4","title":"Common Causes","text":"
  1. Wrong email - Typo in email address
  2. User deleted - Account removed from database
  3. Wrong database - Connected to wrong environment
  4. Case sensitivity - Email stored differently
"},{"location":"v2/troubleshooting/common-errors/#solutions_4","title":"Solutions","text":"

Solution 1: Verify user exists

# Check database\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT email, role FROM \\\"User\\\" WHERE email ILIKE '%search%';\"\n

Solution 2: Check email format

Emails are stored lowercase:

-- Find user case-insensitive\nSELECT * FROM \"User\" WHERE LOWER(email) = LOWER('User@Example.com');\n

Solution 3: Create user if missing

# Via API\ncurl -X POST http://localhost:4000/api/auth/register \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"email\": \"user@example.com\",\n    \"password\": \"SecurePass123!\",\n    \"name\": \"User Name\"\n  }'\n\n# Or via admin UI at /app/users\n

Solution 4: Check database connection

# Verify correct database\ndocker compose exec api npx prisma db pull\n\n# Check DATABASE_URL in .env\ncat .env | grep DATABASE_URL\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_4","title":"Prevention","text":""},{"location":"v2/troubleshooting/common-errors/#api-errors","title":"API Errors","text":""},{"location":"v2/troubleshooting/common-errors/#500-internal-server-error","title":"500 Internal Server Error","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/common-errors/#symptoms_5","title":"Symptoms","text":"
{\n  \"error\": \"Internal Server Error\",\n  \"message\": \"An unexpected error occurred\"\n}\n

Or frontend error:

Error: Request failed with status code 500\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_5","title":"Common Causes","text":"
  1. Unhandled exception - Code threw unexpected error
  2. Database error - Query failed
  3. Missing environment variable - Required config missing
  4. Type error - Runtime type mismatch
"},{"location":"v2/troubleshooting/common-errors/#solutions_5","title":"Solutions","text":"

Solution 1: Check API logs

# View recent logs\ndocker compose logs api --tail=100\n\n# Follow logs in real-time\ndocker compose logs -f api\n\n# Search for errors\ndocker compose logs api | grep -i error | tail -20\n

Solution 2: Common error patterns

// Missing environment variable\nError: SMTP_HOST is required\n// Solution: Add to .env\n\n// Database connection error\nError: Can't reach database server at `v2-postgres:5432`\n// Solution: Check database is running\n\n// Type error\nTypeError: Cannot read property 'id' of undefined\n// Solution: Check code for null checks\n

Solution 3: Restart API

# Restart API container\ndocker compose restart api\n\n# Or rebuild if code changed\ndocker compose up -d --build api\n

Solution 4: Enable debug logging

# In .env\nLOG_LEVEL=debug\n\n# Restart API\ndocker compose restart api\n\n# Check detailed logs\ndocker compose logs api\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_5","title":"Prevention","text":"

Production Alert

500 errors indicate bugs. Always investigate and fix root cause.

"},{"location":"v2/troubleshooting/common-errors/#400-bad-request","title":"400 Bad Request","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_6","title":"Symptoms","text":"
{\n  \"error\": \"Bad Request\",\n  \"message\": \"Invalid request format\"\n}\n

Or with validation details:

{\n  \"error\": \"Bad Request\",\n  \"message\": \"Validation failed: 2 errors\"\n}\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_6","title":"Common Causes","text":"
  1. Invalid JSON - Malformed request body
  2. Wrong Content-Type - Missing or incorrect header
  3. Missing required field - Required parameter not sent
  4. Invalid data type - String sent for number field
"},{"location":"v2/troubleshooting/common-errors/#solutions_6","title":"Solutions","text":"

Solution 1: Check request format

// Correct format\nconst response = await api.post('/api/users', {\n  email: 'user@example.com',\n  password: 'SecurePass123!',\n  name: 'User Name'\n}, {\n  headers: {\n    'Content-Type': 'application/json'\n  }\n});\n\n// Common mistakes:\n// \u274c Missing Content-Type header\n// \u274c Sending FormData to JSON endpoint\n// \u274c Malformed JSON (trailing comma, unquoted keys)\n

Solution 2: Validate against schema

Check API schema in api/src/modules/*/schemas.ts:

// Example: User creation schema\nexport const createUserSchema = z.object({\n  email: z.string().email(),\n  password: z.string().min(12),\n  name: z.string().min(1),\n  role: z.enum(['USER', 'MAP_ADMIN', 'INFLUENCE_ADMIN', 'SUPER_ADMIN']).optional()\n});\n

Solution 3: Check API logs for details

# Logs show validation errors\ndocker compose logs api | grep \"Validation failed\"\n\n# Example output:\n# Validation failed: {\n#   \"email\": \"Invalid email format\",\n#   \"password\": \"Must be at least 12 characters\"\n# }\n

Solution 4: Test with curl

# Test request\ncurl -X POST http://localhost:4000/api/users \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\n    \"email\": \"test@example.com\",\n    \"password\": \"SecurePass123!\",\n    \"name\": \"Test User\"\n  }'\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_6","title":"Prevention","text":""},{"location":"v2/troubleshooting/common-errors/#404-not-found","title":"404 Not Found","text":"

Severity: \ud83d\udfe2 Low to \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_7","title":"Symptoms","text":"
{\n  \"error\": \"Not Found\",\n  \"message\": \"Resource not found\"\n}\n

Or specific:

{\n  \"error\": \"Not Found\",\n  \"message\": \"Campaign not found\"\n}\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_7","title":"Common Causes","text":"
  1. Wrong ID - Resource doesn't exist
  2. Wrong URL - Typo in endpoint path
  3. Deleted resource - Resource was deleted
  4. Wrong HTTP method - GET instead of POST
"},{"location":"v2/troubleshooting/common-errors/#solutions_7","title":"Solutions","text":"

Solution 1: Verify resource exists

# Check database\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, name FROM \\\"Campaign\\\" WHERE id = 'YOUR_ID';\"\n

Solution 2: Check URL format

// Correct formats\nGET /api/campaigns/:id          // Single campaign\nGET /api/campaigns              // List campaigns\nPOST /api/campaigns             // Create campaign\nPUT /api/campaigns/:id          // Update campaign\nDELETE /api/campaigns/:id       // Delete campaign\n\n// Common mistakes:\n// \u274c /api/campaign/:id (singular, should be plural)\n// \u274c /api/campaigns/id/:id (extra 'id/' in path)\n// \u274c /api/campaign (wrong singular/plural)\n

Solution 3: Check route registration

# API logs show registered routes on startup\ndocker compose logs api | grep \"Registered route\"\n\n# Or check routes file\ncat api/src/modules/*/routes.ts\n

Solution 4: Test endpoint

# List all campaigns to verify endpoint\ncurl http://localhost:4000/api/campaigns\n\n# Test specific ID\ncurl http://localhost:4000/api/campaigns/YOUR_ID\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_7","title":"Prevention","text":""},{"location":"v2/troubleshooting/common-errors/#422-unprocessable-entity","title":"422 Unprocessable Entity","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_8","title":"Symptoms","text":"
{\n  \"error\": \"Unprocessable Entity\",\n  \"message\": \"Validation failed\",\n  \"details\": {\n    \"email\": \"Email already exists\",\n    \"password\": \"Must contain uppercase, lowercase, and digit\"\n  }\n}\n
"},{"location":"v2/troubleshooting/common-errors/#common-causes_8","title":"Common Causes","text":"
  1. Business logic violation - Email already exists
  2. Data integrity - Foreign key doesn't exist
  3. Complex validation - Password requirements not met
  4. State conflict - Can't delete resource in use
"},{"location":"v2/troubleshooting/common-errors/#solutions_8","title":"Solutions","text":"

Solution 1: Read validation details

The details field shows exactly what's wrong:

try {\n  await api.post('/api/users', userData);\n} catch (error) {\n  if (error.response?.status === 422) {\n    console.log('Validation errors:', error.response.data.details);\n    // Show to user field-by-field\n  }\n}\n

Solution 2: Check constraints

# Email uniqueness\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT email FROM \\\"User\\\" WHERE email = 'test@example.com';\"\n\n# Foreign key exists\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id FROM \\\"Campaign\\\" WHERE id = 'CAMPAIGN_ID';\"\n

Solution 3: Fix data

Common fixes:

// Email already exists \u2192 Use different email\nemail: 'newuser@example.com'\n\n// Password too weak \u2192 Meet requirements\npassword: 'SecurePass123!'  // 12+ chars, upper, lower, digit\n\n// Foreign key missing \u2192 Create parent first\n// Create campaign before creating email\n\n// Resource in use \u2192 Delete dependents first\n// Delete locations before deleting cut\n

Solution 4: Check database schema

# View constraints\ndocker compose exec api npx prisma studio\n# Navigate to model, see unique fields and relations\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_8","title":"Prevention","text":""},{"location":"v2/troubleshooting/common-errors/#database-errors","title":"Database Errors","text":""},{"location":"v2/troubleshooting/common-errors/#connection-refused","title":"Connection Refused","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/common-errors/#symptoms_9","title":"Symptoms","text":"
Error: connect ECONNREFUSED 127.0.0.1:5433\n

Or:

Error: Can't reach database server at `v2-postgres:5432`\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_9","title":"Common Causes","text":"
  1. Database not running - Container stopped
  2. Wrong connection string - Incorrect host/port
  3. Network issue - Container can't reach database
  4. Port conflict - Port already in use
"},{"location":"v2/troubleshooting/common-errors/#solutions_9","title":"Solutions","text":"

Solution 1: Check database status

# List running containers\ndocker compose ps\n\n# Database should show as \"running\"\n# If not:\ndocker compose up -d v2-postgres\n

Solution 2: Verify connection string

# Check .env\ncat .env | grep DATABASE_URL\n\n# Should be (from API container):\nDATABASE_URL=\"postgresql://changemaker:PASSWORD@v2-postgres:5432/changemaker_v2\"\n\n# Or (from host):\nDATABASE_URL=\"postgresql://changemaker:PASSWORD@localhost:5433/changemaker_v2\"\n

Solution 3: Check database logs

# View database logs\ndocker compose logs v2-postgres\n\n# Look for:\n# - \"database system is ready to accept connections\" (good)\n# - \"FATAL: password authentication failed\" (bad - wrong password)\n# - \"port 5432 already in use\" (bad - port conflict)\n

Solution 4: Test connection manually

# From host\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"SELECT NOW();\"\n\n# Should return current timestamp\n# If fails, database isn't running properly\n

Solution 5: Restart database

# Restart database container\ndocker compose restart v2-postgres\n\n# Or recreate if corrupted\ndocker compose down v2-postgres\ndocker compose up -d v2-postgres\n\n# Wait for \"ready to accept connections\" message\ndocker compose logs -f v2-postgres\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_9","title":"Prevention","text":""},{"location":"v2/troubleshooting/common-errors/#too-many-connections","title":"Too Many Connections","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/common-errors/#symptoms_10","title":"Symptoms","text":"
Error: too many connections for database \"changemaker_v2\"\n

Or:

Error: Prepared statement \"prisma_xxx\" already exists\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_10","title":"Common Causes","text":"
  1. Connection leak - Connections not released
  2. Pool too small - Not enough connections for load
  3. Long-running queries - Blocking connections
  4. Multiple clients - Too many Prisma instances
"},{"location":"v2/troubleshooting/common-errors/#solutions_10","title":"Solutions","text":"

Solution 1: Check active connections

# View connections\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT count(*) FROM pg_stat_activity WHERE datname = 'changemaker_v2';\"\n\n# PostgreSQL default max: 100 connections\n# Prisma default pool: 10 connections\n

Solution 2: Kill idle connections

-- Find idle connections\nSELECT pid, usename, state, query_start\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2' AND state = 'idle';\n\n-- Kill specific connection\nSELECT pg_terminate_backend(PID_HERE);\n\n-- Kill all idle connections (careful!)\nSELECT pg_terminate_backend(pid)\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2' AND state = 'idle';\n

Solution 3: Adjust connection pool

In api/prisma/schema.prisma:

datasource db {\n  provider = \"postgresql\"\n  url      = env(\"DATABASE_URL\")\n  // Add connection pool config\n  // connectionLimit = 10  // Default\n}\n

Or via DATABASE_URL:

DATABASE_URL=\"postgresql://user:pass@host:5432/db?connection_limit=20\"\n

Solution 4: Restart API

# Restart releases all connections\ndocker compose restart api\n\n# Check if connections cleared\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT count(*) FROM pg_stat_activity WHERE datname = 'changemaker_v2';\"\n

Solution 5: Increase PostgreSQL max connections

In docker-compose.yml:

v2-postgres:\n  # ...\n  command: postgres -c max_connections=200\n

Then restart:

docker compose up -d v2-postgres\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_10","title":"Prevention","text":""},{"location":"v2/troubleshooting/common-errors/#unique-constraint-violation","title":"Unique Constraint Violation","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_11","title":"Symptoms","text":"
Error: Unique constraint failed on the fields: (`email`)\n

Or:

PrismaClientKnownRequestError:\nUnique constraint failed on the constraint: `User_email_key`\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_11","title":"Common Causes","text":"
  1. Duplicate email - User already exists
  2. Race condition - Two creates at same time
  3. Case sensitivity - Email differs only in case
  4. Retry logic - Request sent multiple times
"},{"location":"v2/troubleshooting/common-errors/#solutions_11","title":"Solutions","text":"

Solution 1: Check existing records

# Find duplicate\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, email, \\\"createdAt\\\" FROM \\\"User\\\" WHERE email = 'duplicate@example.com';\"\n

Solution 2: Update instead of create

// Instead of:\nawait prisma.user.create({ data: { email, ... } });\n\n// Use upsert:\nawait prisma.user.upsert({\n  where: { email },\n  update: { name, ... },\n  create: { email, name, ... }\n});\n

Solution 3: Handle error gracefully

try {\n  await prisma.user.create({ data });\n} catch (error) {\n  if (error.code === 'P2002') {\n    // Unique constraint violation\n    const field = error.meta?.target?.[0];\n    throw new Error(`${field} already exists`);\n  }\n  throw error;\n}\n

Solution 4: Delete duplicate

# If truly duplicate, delete one\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"DELETE FROM \\\"User\\\" WHERE id = 'ID_TO_DELETE';\"\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_11","title":"Prevention","text":""},{"location":"v2/troubleshooting/common-errors/#foreign-key-constraint","title":"Foreign Key Constraint","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_12","title":"Symptoms","text":"
Error: Foreign key constraint failed on the field: `campaignId`\n

Or:

Error: An operation failed because it depends on one or more records that were required but not found. Record to update not found.\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_12","title":"Common Causes","text":"
  1. Parent doesn't exist - Referenced record missing
  2. Wrong ID - Typo in foreign key value
  3. Delete order - Trying to delete parent before children
  4. Null constraint - Foreign key required but null provided
"},{"location":"v2/troubleshooting/common-errors/#solutions_12","title":"Solutions","text":"

Solution 1: Verify parent exists

# Check campaign exists\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, name FROM \\\"Campaign\\\" WHERE id = 'CAMPAIGN_ID';\"\n

Solution 2: Create parent first

// Create campaign first\nconst campaign = await prisma.campaign.create({\n  data: { name: 'My Campaign', ... }\n});\n\n// Then create email with campaignId\nconst email = await prisma.campaignEmail.create({\n  data: {\n    campaignId: campaign.id,  // Use created campaign's ID\n    ...\n  }\n});\n

Solution 3: Delete children first

// Delete all emails in campaign\nawait prisma.campaignEmail.deleteMany({\n  where: { campaignId }\n});\n\n// Then delete campaign\nawait prisma.campaign.delete({\n  where: { id: campaignId }\n});\n\n// Or use cascade delete in schema:\n// @@relation(onDelete: Cascade)\n

Solution 4: Use transactions

// Ensure atomicity\nawait prisma.$transaction([\n  prisma.campaignEmail.deleteMany({ where: { campaignId } }),\n  prisma.campaign.delete({ where: { id: campaignId } })\n]);\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_12","title":"Prevention","text":""},{"location":"v2/troubleshooting/common-errors/#frontend-errors","title":"Frontend Errors","text":""},{"location":"v2/troubleshooting/common-errors/#network-error","title":"Network Error","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/common-errors/#symptoms_13","title":"Symptoms","text":"

Browser console:

Error: Network Error\n

Or:

AxiosError: Request failed with status code undefined\n

User sees: API request fails, loading spinner never stops.

"},{"location":"v2/troubleshooting/common-errors/#common-causes_13","title":"Common Causes","text":"
  1. API down - API container not running
  2. Wrong API URL - VITE_API_URL misconfigured
  3. CORS issue - Browser blocking request
  4. Network timeout - Request taking too long
"},{"location":"v2/troubleshooting/common-errors/#solutions_13","title":"Solutions","text":"

Solution 1: Check API status

# Is API running?\ndocker compose ps api\n\n# Check API logs\ndocker compose logs api --tail=50\n\n# Test API directly\ncurl http://localhost:4000/api/health\n

Solution 2: Verify API URL

# Check admin .env\ncat admin/.env\n\n# Should have:\nVITE_API_URL=http://localhost:4000\n\n# In Docker, use:\nVITE_API_URL=http://api:4000\n

Solution 3: Check browser console

Press F12, check:

Solution 4: Test from different client

# From command line\ncurl http://localhost:4000/api/campaigns\n\n# If this works but browser doesn't, it's a CORS issue\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_13","title":"Prevention","text":""},{"location":"v2/troubleshooting/common-errors/#cors-errors","title":"CORS Errors","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/common-errors/#symptoms_14","title":"Symptoms","text":"

Browser console:

Access to XMLHttpRequest at 'http://localhost:4000/api/users' from origin\n'http://localhost:3000' has been blocked by CORS policy: No\n'Access-Control-Allow-Origin' header is present on the requested resource.\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_14","title":"Common Causes","text":"
  1. Missing CORS config - API not configured for CORS
  2. Wrong origin - Admin URL not in allowed origins
  3. Credentials flag - withCredentials set but not allowed
  4. Preflight failure - OPTIONS request failing
"},{"location":"v2/troubleshooting/common-errors/#solutions_14","title":"Solutions","text":"

Solution 1: Check API CORS configuration

In api/src/server.ts:

app.use(cors({\n  origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'],\n  credentials: true\n}));\n

Solution 2: Verify CORS_ORIGINS

# Check .env\ncat .env | grep CORS_ORIGINS\n\n# Should include admin URL:\nCORS_ORIGINS=http://localhost:3000,https://app.cmlite.org\n

Solution 3: Add origin temporarily

For development:

# In .env\nCORS_ORIGINS=*  # Allow all origins (dev only!)\n\n# Restart API\ndocker compose restart api\n

Solution 4: Check preflight request

In browser Network tab:

  1. Find OPTIONS request before actual request
  2. Check if it returns 200 OK
  3. Check response headers include:
  4. Access-Control-Allow-Origin
  5. Access-Control-Allow-Methods
  6. Access-Control-Allow-Headers
"},{"location":"v2/troubleshooting/common-errors/#prevention_14","title":"Prevention","text":"

Security Note

Never use CORS_ORIGINS=* in production with credentials enabled.

"},{"location":"v2/troubleshooting/common-errors/#module-not-found","title":"Module Not Found","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_15","title":"Symptoms","text":"
Error: Cannot find module '@/components/MyComponent'\n

Or:

Module not found: Can't resolve 'some-package'\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_15","title":"Common Causes","text":"
  1. Missing dependency - Package not installed
  2. Wrong import path - Typo in path
  3. Path alias issue - @ alias not configured
  4. Case sensitivity - Wrong case in filename
"},{"location":"v2/troubleshooting/common-errors/#solutions_15","title":"Solutions","text":"

Solution 1: Install missing package

cd admin\n\n# Install package\nnpm install some-package\n\n# Or if dev dependency\nnpm install -D some-package\n\n# Restart dev server\nnpm run dev\n

Solution 2: Check import path

// Wrong:\nimport MyComponent from '@/Component/MyComponent';\n\n// Right:\nimport MyComponent from '@/components/MyComponent';\n\n// Verify file exists:\n// admin/src/components/MyComponent.tsx\n

Solution 3: Verify path alias

In admin/vite.config.ts:

export default defineConfig({\n  resolve: {\n    alias: {\n      '@': path.resolve(__dirname, './src')\n    }\n  }\n});\n

In admin/tsconfig.json:

{\n  \"compilerOptions\": {\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  }\n}\n

Solution 4: Clear cache and reinstall

cd admin\n\n# Remove node_modules and lock file\nrm -rf node_modules package-lock.json\n\n# Reinstall\nnpm install\n\n# Restart\nnpm run dev\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_15","title":"Prevention","text":""},{"location":"v2/troubleshooting/common-errors/#hydration-errors","title":"Hydration Errors","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_16","title":"Symptoms","text":"

Browser console:

Warning: Text content did not match. Server: \"...\" Client: \"...\"\n

Or:

Error: Hydration failed because the initial UI does not match what was\nrendered on the server.\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_16","title":"Common Causes","text":"
  1. Date formatting - Server/client timezone difference
  2. Random values - Using Math.random() or uuid
  3. localStorage - Reading from localStorage during render
  4. User agent - Checking window.navigator during SSR
  5. Third-party scripts - Injected by browser extensions
"},{"location":"v2/troubleshooting/common-errors/#solutions_16","title":"Solutions","text":"

Solution 1: Use useEffect for client-only code

// Wrong:\nconst Component = () => {\n  const value = localStorage.getItem('key');\n  return <div>{value}</div>;\n};\n\n// Right:\nconst Component = () => {\n  const [value, setValue] = useState<string | null>(null);\n\n  useEffect(() => {\n    setValue(localStorage.getItem('key'));\n  }, []);\n\n  return <div>{value}</div>;\n};\n

Solution 2: Consistent date formatting

// Wrong:\n<div>{new Date().toLocaleString()}</div>  // Varies by locale\n\n// Right:\nimport dayjs from 'dayjs';\n<div>{dayjs().format('YYYY-MM-DD HH:mm:ss')}</div>\n

Solution 3: suppressHydrationWarning for known mismatches

// For values that intentionally differ (like timestamps)\n<time suppressHydrationWarning>\n  {new Date().toISOString()}\n</time>\n

Solution 4: Check browser extensions

Disable browser extensions temporarily to see if error persists.

"},{"location":"v2/troubleshooting/common-errors/#prevention_16","title":"Prevention","text":"

Changemaker Lite V2

Current admin is CSR (Client-Side Rendered) only, so hydration errors shouldn't occur. This section is for future SSR/SSG implementations.

"},{"location":"v2/troubleshooting/common-errors/#file-upload-errors","title":"File Upload Errors","text":""},{"location":"v2/troubleshooting/common-errors/#file-too-large","title":"File Too Large","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_17","title":"Symptoms","text":"
{\n  \"error\": \"Payload Too Large\",\n  \"message\": \"File size exceeds maximum of 10485760 bytes\"\n}\n

Or browser:

Request Entity Too Large\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_17","title":"Common Causes","text":"
  1. File exceeds limit - Video larger than 10GB
  2. Nginx limit - Reverse proxy blocking
  3. Wrong content type - Not multipart/form-data
  4. Network timeout - Upload taking too long
"},{"location":"v2/troubleshooting/common-errors/#solutions_17","title":"Solutions","text":"

Solution 1: Check file size

// Before upload\nconst file = event.target.files[0];\nconst maxSize = 10 * 1024 * 1024 * 1024; // 10GB\n\nif (file.size > maxSize) {\n  alert(`File too large. Max size: ${maxSize / 1024 / 1024 / 1024}GB`);\n  return;\n}\n

Solution 2: Increase limits

In api/src/modules/media/routes/upload.routes.ts:

fastify.register(multipart, {\n  limits: {\n    fileSize: 10 * 1024 * 1024 * 1024  // 10GB\n  }\n});\n

In nginx/conf.d/api.conf:

client_max_body_size 10G;\n

Solution 3: Use chunked upload

For very large files, implement resumable upload:

// TODO: Implement chunked upload in Phase 15\n

Solution 4: Compress video

# Before uploading, compress with ffmpeg\nffmpeg -i input.mp4 -c:v libx264 -crf 23 -c:a aac output.mp4\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_17","title":"Prevention","text":""},{"location":"v2/troubleshooting/common-errors/#invalid-file-type","title":"Invalid File Type","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/common-errors/#symptoms_18","title":"Symptoms","text":"
{\n  \"error\": \"Bad Request\",\n  \"message\": \"Invalid file type. Allowed: mp4, mov, avi, mkv, webm, m4v, flv\"\n}\n
"},{"location":"v2/troubleshooting/common-errors/#common-causes_18","title":"Common Causes","text":"
  1. Wrong extension - File has unsupported extension
  2. Missing extension - Filename has no extension
  3. Mismatched extension - Extension doesn't match content
  4. MIME type issue - Browser sends wrong MIME type
"},{"location":"v2/troubleshooting/common-errors/#solutions_18","title":"Solutions","text":"

Solution 1: Check supported formats

Supported video formats:

Solution 2: Convert video

# Convert to MP4 (most compatible)\nffmpeg -i input.avi -c:v libx264 -c:a aac output.mp4\n

Solution 3: Check file extension

const file = event.target.files[0];\nconst ext = file.name.split('.').pop().toLowerCase();\nconst allowed = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'flv'];\n\nif (!allowed.includes(ext)) {\n  alert(`Invalid file type: .${ext}`);\n  return;\n}\n

Solution 4: Verify with file command

# Check actual file type\nfile video.mp4\n\n# Should show:\n# video.mp4: ISO Media, MP4 v2 [ISO 14496-14]\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_18","title":"Prevention","text":""},{"location":"v2/troubleshooting/common-errors/#upload-timeout","title":"Upload Timeout","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_19","title":"Symptoms","text":"
Error: timeout of 30000ms exceeded\n

Or:

504 Gateway Timeout\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_19","title":"Common Causes","text":"
  1. Slow network - Large file, slow connection
  2. Server timeout - Request timeout too short
  3. Processing delay - FFprobe taking too long
  4. Network interruption - Connection dropped
"},{"location":"v2/troubleshooting/common-errors/#solutions_19","title":"Solutions","text":"

Solution 1: Increase timeout

In admin/src/lib/media-api.ts:

export const mediaApi = axios.create({\n  baseURL: import.meta.env.VITE_MEDIA_API_URL,\n  timeout: 300000  // 5 minutes instead of 30 seconds\n});\n

Solution 2: Check upload progress

await mediaApi.post('/upload', formData, {\n  onUploadProgress: (progressEvent) => {\n    const percent = (progressEvent.loaded / progressEvent.total) * 100;\n    console.log(`Upload: ${percent.toFixed(2)}%`);\n  }\n});\n

Solution 3: Increase nginx timeout

In nginx/conf.d/api.conf:

proxy_read_timeout 300s;\nproxy_connect_timeout 300s;\nproxy_send_timeout 300s;\n

Solution 4: Upload via chunks

// TODO: Implement chunked upload for large files\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_19","title":"Prevention","text":""},{"location":"v2/troubleshooting/common-errors/#email-errors","title":"Email Errors","text":""},{"location":"v2/troubleshooting/common-errors/#smtp-connection-failed","title":"SMTP Connection Failed","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/common-errors/#symptoms_20","title":"Symptoms","text":"

API logs:

Error: Connection timeout\nError: connect ECONNREFUSED 127.0.0.1:587\n

Or:

Error: Invalid login: 535-5.7.8 Username and Password not accepted\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_20","title":"Common Causes","text":"
  1. SMTP server down - Mail server unreachable
  2. Wrong credentials - Invalid username/password
  3. Port blocked - Firewall blocking SMTP port
  4. TLS/SSL issue - Certificate validation failed
"},{"location":"v2/troubleshooting/common-errors/#solutions_20","title":"Solutions","text":"

Solution 1: Test SMTP connection

# Test with telnet\ntelnet smtp.gmail.com 587\n\n# Should connect and show:\n# 220 smtp.gmail.com ESMTP...\n

Solution 2: Verify SMTP configuration

# Check .env\ncat .env | grep SMTP\n\n# Required settings:\nSMTP_HOST=smtp.gmail.com\nSMTP_PORT=587\nSMTP_SECURE=false\nSMTP_USER=your-email@gmail.com\nSMTP_PASS=your-app-password\nSMTP_FROM=your-email@gmail.com\n

Solution 3: Use test mode

# In .env\nEMAIL_TEST_MODE=true\n\n# Restart API\ndocker compose restart api\n\n# Emails now sent to MailHog (http://localhost:8025)\n

Solution 4: Check Gmail app password

For Gmail:

  1. Enable 2-factor authentication
  2. Generate app password at https://myaccount.google.com/apppasswords
  3. Use app password (not regular password) in SMTP_PASS

Solution 5: Test with curl

# Send test email via API\ncurl -X POST http://localhost:4000/api/test-email \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\n    \"to\": \"test@example.com\",\n    \"subject\": \"Test Email\",\n    \"text\": \"This is a test\"\n  }'\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_20","title":"Prevention","text":""},{"location":"v2/troubleshooting/common-errors/#template-not-found","title":"Template Not Found","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/common-errors/#symptoms_21","title":"Symptoms","text":"

API logs:

Error: Email template not found: campaign-email\n

Or:

Error: ENOENT: no such file or directory, open 'templates/campaign-email.html'\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_21","title":"Common Causes","text":"
  1. Missing template file - Template not created
  2. Wrong template name - Typo in template name
  3. Wrong path - Looking in wrong directory
  4. Deleted template - Template was removed
"},{"location":"v2/troubleshooting/common-errors/#solutions_21","title":"Solutions","text":"

Solution 1: Check template exists

# List all templates\ndocker compose exec api ls -la templates/\n\n# Should show:\n# campaign-email.html\n# shift-confirmation.html\n# verification-email.html\n# etc.\n

Solution 2: Verify template name

In api/src/services/email.service.ts:

// Template names must match filenames (without .html)\nawait emailService.sendEmail({\n  to: email,\n  subject: 'Campaign Email',\n  template: 'campaign-email',  // Looks for templates/campaign-email.html\n  variables: { ... }\n});\n

Solution 3: Create missing template

# Create template\ndocker compose exec api sh -c 'cat > templates/my-template.html << EOF\n<!DOCTYPE html>\n<html>\n<body>\n  <h1>Hello {{name}}</h1>\n  <p>{{message}}</p>\n</body>\n</html>\nEOF'\n

Solution 4: Use email template system

# Navigate to admin UI\nhttp://localhost:3000/app/email-templates\n\n# Create template there (saved to database + file)\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_21","title":"Prevention","text":""},{"location":"v2/troubleshooting/common-errors/#variable-missing","title":"Variable Missing","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_22","title":"Symptoms","text":"

Email received with placeholders not replaced:

Hello {{name}},\nYour campaign {{campaignName}} is ready.\n

Or API logs:

Warning: Template variable 'campaignName' not provided\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_22","title":"Common Causes","text":"
  1. Variable not passed - Missing from variables object
  2. Variable name mismatch - Typo in variable name
  3. Wrong template - Using wrong template
  4. Case sensitivity - Variable name case mismatch
"},{"location":"v2/troubleshooting/common-errors/#solutions_22","title":"Solutions","text":"

Solution 1: Check template variables

In template file:

<!-- templates/campaign-email.html -->\n<h1>Hello {{firstName}}</h1>\n<p>Your campaign \"{{campaignName}}\" is ready.</p>\n<p>Visit: {{campaignUrl}}</p>\n

Solution 2: Provide all variables

await emailService.sendEmail({\n  to: email,\n  subject: 'Campaign Ready',\n  template: 'campaign-email',\n  variables: {\n    firstName: user.name.split(' ')[0],\n    campaignName: campaign.name,\n    campaignUrl: `${process.env.PUBLIC_URL}/campaigns/${campaign.id}`\n  }\n});\n

Solution 3: Use default values

<!-- In template, provide fallback -->\n<h1>Hello {{firstName || 'Friend'}}</h1>\n

Solution 4: Validate before sending

// Check all required variables exist\nconst required = ['firstName', 'campaignName', 'campaignUrl'];\nconst missing = required.filter(key => !variables[key]);\n\nif (missing.length > 0) {\n  throw new Error(`Missing template variables: ${missing.join(', ')}`);\n}\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_22","title":"Prevention","text":""},{"location":"v2/troubleshooting/common-errors/#quick-reference-table","title":"Quick Reference Table","text":"Error Code/Message Category Common Cause Quick Fix Severity 401 Unauthorized Auth Token expired Re-login \ud83d\udfe0 403 Forbidden Auth Wrong role Check user role \ud83d\udfe0 404 Not Found API Wrong URL/ID Verify resource exists \ud83d\udfe2 422 Unprocessable Validation Constraint violation Check validation details \ud83d\udfe1 500 Server Error API Code bug Check API logs \ud83d\udd34 ECONNREFUSED Database DB not running Start database \ud83d\udd34 Too many connections Database Connection leak Restart API \ud83d\udfe0 Unique constraint Database Duplicate record Use upsert or different value \ud83d\udfe1 Foreign key constraint Database Parent missing Create parent first \ud83d\udfe1 Network Error Frontend API down Check API status \ud83d\udfe0 CORS Error Frontend Origin not allowed Add to CORS_ORIGINS \ud83d\udfe0 Module not found Frontend Missing package npm install \ud83d\udfe1 File too large Upload Exceeds 10GB Compress or increase limit \ud83d\udfe1 Invalid file type Upload Wrong format Convert to MP4 \ud83d\udfe2 Upload timeout Upload Slow network Increase timeout \ud83d\udfe1 SMTP failed Email Wrong credentials Check SMTP config \ud83d\udd34 Template not found Email Missing file Create template \ud83d\udfe0 Variable missing Email Not provided Add to variables object \ud83d\udfe1"},{"location":"v2/troubleshooting/common-errors/#when-to-report-bugs","title":"When to Report Bugs","text":""},{"location":"v2/troubleshooting/common-errors/#report-these","title":"Report These","text":"

\u2705 Unexpected behavior - System does something wrong

\u2705 Missing features - Documented feature doesn't work

\u2705 Unclear documentation - Can't figure out how to do something

"},{"location":"v2/troubleshooting/common-errors/#dont-report-these","title":"Don't Report These","text":"

\u274c Configuration errors - Your setup is wrong

\u274c Environment issues - Your system is incompatible

\u274c User errors - Misunderstanding how to use

"},{"location":"v2/troubleshooting/common-errors/#how-to-report","title":"How to Report","text":"
  1. Check this troubleshooting guide first
  2. Search existing GitHub issues
  3. If new, create issue with:
  4. Clear title describing problem
  5. Steps to reproduce
  6. Expected vs actual behavior
  7. Relevant logs (sanitize sensitive data)
  8. System information (Docker version, OS, etc.)
"},{"location":"v2/troubleshooting/common-errors/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/common-errors/#general-documentation","title":"General Documentation","text":""},{"location":"v2/troubleshooting/common-errors/#specific-troubleshooting","title":"Specific Troubleshooting","text":""},{"location":"v2/troubleshooting/common-errors/#support","title":"Support","text":"

Last Updated: February 2026 Version: V2.0 Status: Complete

"},{"location":"v2/troubleshooting/database-issues/","title":"Database and PostgreSQL Issues","text":"

This guide covers PostgreSQL and database-related problems in Changemaker Lite V2.

"},{"location":"v2/troubleshooting/database-issues/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/database-issues/#database-architecture","title":"Database Architecture","text":"

Changemaker Lite V2 uses:

"},{"location":"v2/troubleshooting/database-issues/#database-connection-info","title":"Database Connection Info","text":"
# From API container\nDATABASE_URL=\"postgresql://changemaker:password@v2-postgres:5432/changemaker_v2\"\n\n# From host\nDATABASE_URL=\"postgresql://changemaker:password@localhost:5433/changemaker_v2\"\n\n# Connection details:\n# User: changemaker\n# Password: set in V2_POSTGRES_PASSWORD env var\n# Host: v2-postgres (container) or localhost (host)\n# Port: 5432 (inside Docker), 5433 (host)\n# Database: changemaker_v2\n
"},{"location":"v2/troubleshooting/database-issues/#essential-commands","title":"Essential Commands","text":"
# Connect to database\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2\n\n# Run single query\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"SELECT NOW();\"\n\n# Run SQL file\ndocker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < script.sql\n\n# Database logs\ndocker compose logs v2-postgres\n\n# Prisma Studio (GUI)\ndocker compose exec api npx prisma studio\n
"},{"location":"v2/troubleshooting/database-issues/#connection-errors","title":"Connection Errors","text":""},{"location":"v2/troubleshooting/database-issues/#connection-refused","title":"Connection Refused","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/database-issues/#symptoms","title":"Symptoms","text":"

API logs:

Error: connect ECONNREFUSED 127.0.0.1:5433\nError: Can't reach database server at `v2-postgres:5432`\n

Or direct connection:

psql: error: connection to server at \"localhost\" (127.0.0.1), port 5433 failed:\nConnection refused\n

"},{"location":"v2/troubleshooting/database-issues/#common-causes","title":"Common Causes","text":"
  1. Database not running - Container stopped
  2. Wrong connection string - Incorrect host/port
  3. Port not exposed - Missing port mapping
  4. Network issue - Container can't reach database
"},{"location":"v2/troubleshooting/database-issues/#solutions","title":"Solutions","text":"

Solution 1: Check database status

# Is database running?\ndocker compose ps v2-postgres\n\n# Should show:\n# NAME                          STATUS\n# changemaker-lite-v2-postgres-1   Up 5 minutes\n\n# If not running:\ndocker compose up -d v2-postgres\n

Solution 2: Wait for database to be ready

# Check logs for \"ready to accept connections\"\ndocker compose logs v2-postgres | grep \"ready\"\n\n# Should show:\n# database system is ready to accept connections\n\n# If not ready, wait 10-20 seconds and check again\n

Solution 3: Verify connection string

# Check .env\ncat .env | grep DATABASE_URL\n\n# From API container should use container name:\nDATABASE_URL=\"postgresql://changemaker:password@v2-postgres:5432/changemaker_v2\"\n\n# From host should use localhost:\nDATABASE_URL=\"postgresql://changemaker:password@localhost:5433/changemaker_v2\"\n\n# Common mistakes:\n# \u274c Using localhost from container\n# \u274c Using v2-postgres from host\n# \u274c Wrong port (5432 vs 5433)\n# \u274c Wrong password\n

Solution 4: Test connection manually

# From API container\ndocker compose exec api sh -c 'psql $DATABASE_URL -c \"SELECT NOW();\"'\n\n# From host\npsql \"postgresql://changemaker:password@localhost:5433/changemaker_v2\" -c \"SELECT NOW();\"\n\n# If fails, connection string is wrong\n

Solution 5: Check port mapping

In docker-compose.yml:

v2-postgres:\n  ports:\n    - \"5433:5432\"  # host:container\n

Verify:

docker compose ps v2-postgres\n\n# Should show:\n# PORTS: 0.0.0.0:5433->5432/tcp\n
"},{"location":"v2/troubleshooting/database-issues/#prevention","title":"Prevention","text":""},{"location":"v2/troubleshooting/database-issues/#too-many-clients","title":"Too Many Clients","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/database-issues/#symptoms_1","title":"Symptoms","text":"
FATAL: sorry, too many clients already\n

Or:

Error: remaining connection slots are reserved for non-replication superuser connections\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_1","title":"Common Causes","text":"
  1. Connection leak - Connections not closed
  2. Pool too large - Connection pool size too high
  3. Multiple Prisma instances - Each creates own pool
  4. Long-running transactions - Holding connections
"},{"location":"v2/troubleshooting/database-issues/#solutions_1","title":"Solutions","text":"

Solution 1: Check active connections

-- View all connections\nSELECT count(*) FROM pg_stat_activity;\n\n-- View connections by state\nSELECT state, count(*)\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2'\nGROUP BY state;\n\n-- View connection details\nSELECT pid, usename, application_name, state, query_start, query\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2'\nORDER BY query_start;\n

Solution 2: Kill idle connections

-- Find idle connections\nSELECT pid, usename, state, state_change\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2'\n  AND state = 'idle'\n  AND state_change < NOW() - INTERVAL '5 minutes';\n\n-- Kill specific connection\nSELECT pg_terminate_backend(12345);  -- Replace with actual PID\n\n-- Kill all idle connections (careful!)\nSELECT pg_terminate_backend(pid)\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2'\n  AND state = 'idle'\n  AND state_change < NOW() - INTERVAL '5 minutes';\n

Solution 3: Adjust connection pool

In DATABASE_URL:

# Limit connection pool size\nDATABASE_URL=\"postgresql://changemaker:password@v2-postgres:5432/changemaker_v2?connection_limit=10\"\n

Or in Prisma code:

// api/src/config/database.ts\nimport { PrismaClient } from '@prisma/client';\n\nexport const prisma = new PrismaClient({\n  datasources: {\n    db: {\n      url: process.env.DATABASE_URL\n    }\n  }\n  // Connection pool defaults:\n  // connection_limit: 10\n  // pool_timeout: 10 (seconds)\n});\n

Solution 4: Increase max connections

In docker-compose.yml:

v2-postgres:\n  command: postgres -c max_connections=200\n  # Default is 100\n

Restart:

docker compose up -d v2-postgres\n

Verify:

SHOW max_connections;\n

Solution 5: Restart API to release connections

# Restart API releases all connections\ndocker compose restart api\ndocker compose restart media-api\n\n# Check connection count dropped\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT count(*) FROM pg_stat_activity WHERE datname = 'changemaker_v2';\"\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_1","title":"Prevention","text":"

Connection Math

Total connections = (number of API instances) \u00d7 (connection pool size) + (other clients)

Example: - 2 API instances \u00d7 10 pool size = 20 connections - 1 media API \u00d7 5 pool size = 5 connections - Prisma Studio = 1 connection - Total = 26 connections

Set max_connections to 2-3\u00d7 expected usage.

"},{"location":"v2/troubleshooting/database-issues/#authentication-failed","title":"Authentication Failed","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/database-issues/#symptoms_2","title":"Symptoms","text":"
FATAL: password authentication failed for user \"changemaker\"\n

Or:

FATAL: role \"changemaker\" does not exist\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_2","title":"Common Causes","text":"
  1. Wrong password - PASSWORD in DATABASE_URL doesn't match
  2. Wrong username - User doesn't exist
  3. Password changed - Database password changed but not .env
  4. Case sensitivity - PostgreSQL usernames are case-sensitive
"},{"location":"v2/troubleshooting/database-issues/#solutions_2","title":"Solutions","text":"

Solution 1: Verify credentials

# Check .env\ncat .env | grep V2_POSTGRES_PASSWORD\n\n# Check DATABASE_URL\ncat .env | grep DATABASE_URL\n\n# Password in DATABASE_URL must match V2_POSTGRES_PASSWORD\n

Solution 2: Test connection directly

# Test with password\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2\n\n# If prompted for password, enter V2_POSTGRES_PASSWORD\n# If fails, credentials are wrong\n

Solution 3: Check user exists

# Connect as postgres superuser\ndocker compose exec v2-postgres psql -U postgres -c \"\\du\"\n\n# Should show changemaker user:\n# Role name | Attributes\n# changemaker |\n\n# If missing, create user:\ndocker compose exec v2-postgres psql -U postgres -c \\\n  \"CREATE USER changemaker WITH PASSWORD 'your-password';\"\n\ndocker compose exec v2-postgres psql -U postgres -c \\\n  \"GRANT ALL PRIVILEGES ON DATABASE changemaker_v2 TO changemaker;\"\n

Solution 4: Reset password

# As postgres superuser\ndocker compose exec v2-postgres psql -U postgres -c \\\n  \"ALTER USER changemaker WITH PASSWORD 'new-password';\"\n\n# Update .env\nV2_POSTGRES_PASSWORD=new-password\nDATABASE_URL=\"postgresql://changemaker:new-password@v2-postgres:5432/changemaker_v2\"\n\n# Restart API\ndocker compose restart api\n

Solution 5: Recreate database

If completely broken:

# Backup first!\ndocker compose exec v2-postgres pg_dump -U postgres changemaker_v2 > backup.sql\n\n# Stop database\ndocker compose down v2-postgres\n\n# Remove volume (\u26a0\ufe0f DELETES DATA!)\ndocker volume rm changemaker-lite_postgres-data\n\n# Start fresh\ndocker compose up -d v2-postgres\n\n# Wait for ready\ndocker compose logs -f v2-postgres | grep \"ready\"\n\n# Run migrations\ndocker compose exec api npx prisma migrate deploy\ndocker compose exec api npx prisma db seed\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_2","title":"Prevention","text":""},{"location":"v2/troubleshooting/database-issues/#database-does-not-exist","title":"Database Does Not Exist","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/database-issues/#symptoms_3","title":"Symptoms","text":"
FATAL: database \"changemaker_v2\" does not exist\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_3","title":"Common Causes","text":"
  1. First run - Database not created yet
  2. Wrong database name - Typo in DATABASE_URL
  3. Database deleted - Volume was removed
  4. Wrong postgres instance - Connected to different database
"},{"location":"v2/troubleshooting/database-issues/#solutions_3","title":"Solutions","text":"

Solution 1: Check database exists

# List databases\ndocker compose exec v2-postgres psql -U postgres -l\n\n# Should show:\n# Name          | Owner\n# changemaker_v2 | changemaker\n\n# If missing, database wasn't created\n

Solution 2: Create database

# Create database\ndocker compose exec v2-postgres psql -U postgres -c \\\n  \"CREATE DATABASE changemaker_v2 OWNER changemaker;\"\n\n# Verify\ndocker compose exec v2-postgres psql -U postgres -l | grep changemaker_v2\n

Solution 3: Run migrations

# Prisma migrations create tables\ndocker compose exec api npx prisma migrate deploy\n\n# Drizzle push creates media tables\ndocker compose exec api npx drizzle-kit push\n\n# Seed initial data\ndocker compose exec api npx prisma db seed\n

Solution 4: Check DATABASE_URL

# Verify database name in URL\ncat .env | grep DATABASE_URL\n\n# Should end with /changemaker_v2\n# Not:\n# /changemaker (missing _v2)\n# /postgres (wrong database)\n

Solution 5: Full reset

# \u26a0\ufe0f Deletes all data!\ndocker compose down -v\ndocker compose up -d v2-postgres\n\n# Wait for ready\nsleep 10\n\n# Create and migrate\ndocker compose exec api npx prisma migrate deploy\ndocker compose exec api npx drizzle-kit push\ndocker compose exec api npx prisma db seed\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_3","title":"Prevention","text":""},{"location":"v2/troubleshooting/database-issues/#migration-errors","title":"Migration Errors","text":""},{"location":"v2/troubleshooting/database-issues/#migration-conflict","title":"Migration Conflict","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/database-issues/#symptoms_4","title":"Symptoms","text":"
Error: Migration failed to apply cleanly to the shadow database.\nError: P3006 Migration `20260101000000_init` failed to apply cleanly to a temporary database.\n

Or:

Error: The migration `20260201000000_add_field` cannot be applied to the database:\n- Added the required column `fieldName` to the `User` table without a default value.\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_4","title":"Common Causes","text":"
  1. Schema drift - Database schema doesn't match Prisma schema
  2. Non-nullable column - Adding required field to table with data
  3. Conflicting migration - Different migration with same name
  4. Shadow database issue - Can't create shadow database
"},{"location":"v2/troubleshooting/database-issues/#solutions_4","title":"Solutions","text":"

Solution 1: Check migration status

# View migration history\ndocker compose exec api npx prisma migrate status\n\n# Shows:\n# - Applied migrations\n# - Pending migrations\n# - Failed migrations\n

Solution 2: Add default value for new field

If adding non-nullable column to table with existing data:

// In prisma/schema.prisma\nmodel User {\n  id    String @id @default(uuid())\n  email String @unique\n  name  String @default(\"\")  // Add default for existing rows\n}\n

Or use two-step migration:

-- Migration 1: Add nullable field\nALTER TABLE \"User\" ADD COLUMN \"name\" TEXT;\n\n-- Migration 2: Make non-nullable (after backfilling)\nUPDATE \"User\" SET \"name\" = 'Unknown' WHERE \"name\" IS NULL;\nALTER TABLE \"User\" ALTER COLUMN \"name\" SET NOT NULL;\n

Solution 3: Reset database (dev only)

# \u26a0\ufe0f DELETES ALL DATA!\ndocker compose exec api npx prisma migrate reset\n\n# This:\n# 1. Drops database\n# 2. Creates database\n# 3. Applies all migrations\n# 4. Runs seed\n

Solution 4: Manually fix schema drift

# Compare database schema to Prisma schema\ndocker compose exec api npx prisma db pull\n\n# This creates a new schema.prisma from database\n# Compare with your current schema.prisma\n# Manually fix differences\n

Solution 5: Mark migration as applied (if already applied manually)

# If you manually ran migration SQL, mark as applied:\ndocker compose exec api npx prisma migrate resolve --applied \"20260201000000_migration_name\"\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_4","title":"Prevention","text":""},{"location":"v2/troubleshooting/database-issues/#schema-drift","title":"Schema Drift","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/database-issues/#symptoms_5","title":"Symptoms","text":"
Warning: Your database schema is not in sync with your Prisma schema.\n

Or:

Error: P2021 The table `main.NewTable` does not exist in the current database.\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_5","title":"Common Causes","text":"
  1. Manual schema changes - Changed database without migration
  2. Missing migrations - Migrations not run on this database
  3. Different environment - Prod vs dev schema mismatch
  4. Failed migration - Migration partially applied
"},{"location":"v2/troubleshooting/database-issues/#solutions_5","title":"Solutions","text":"

Solution 1: Detect drift

# Check for drift\ndocker compose exec api npx prisma migrate diff \\\n  --from-schema-datamodel prisma/schema.prisma \\\n  --to-schema-datasource prisma/schema.prisma \\\n  --script\n\n# If output is empty, no drift\n# If shows SQL, that's the drift\n

Solution 2: Create migration from drift

# Generate migration to fix drift\ndocker compose exec api npx prisma migrate dev --name fix_drift\n\n# Reviews changes and creates migration\n

Solution 3: Pull schema from database

# Update Prisma schema from database\ndocker compose exec api npx prisma db pull\n\n# This overwrites schema.prisma with actual database schema\n# Review changes before committing\n

Solution 4: Deploy missing migrations

# Apply all pending migrations\ndocker compose exec api npx prisma migrate deploy\n\n# Check status\ndocker compose exec api npx prisma migrate status\n

Solution 5: Reset and re-migrate (dev only)

# \u26a0\ufe0f DELETES ALL DATA!\ndocker compose exec api npx prisma migrate reset\n\n# Applies all migrations fresh\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_5","title":"Prevention","text":""},{"location":"v2/troubleshooting/database-issues/#failed-migration-rollback","title":"Failed Migration Rollback","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/database-issues/#symptoms_6","title":"Symptoms","text":"
Error: Migration failed. Cannot rollback without losing data.\n

Or:

Error: Database is in an inconsistent state after a failed migration\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_6","title":"Common Causes","text":"
  1. Data migration failed - Migration includes data changes that failed
  2. Constraint violation - Migration violates database constraints
  3. No rollback - Prisma doesn't support automatic rollback
  4. Partial application - Migration partially applied before error
"},{"location":"v2/troubleshooting/database-issues/#solutions_6","title":"Solutions","text":"

Solution 1: Mark migration as rolled back

# Mark as failed (doesn't undo changes)\ndocker compose exec api npx prisma migrate resolve --rolled-back \"20260201000000_migration_name\"\n

Solution 2: Manually revert changes

-- Find what migration did\ncat api/prisma/migrations/20260201000000_migration_name/migration.sql\n\n-- Write reverse SQL\n-- If migration did:\nALTER TABLE \"User\" ADD COLUMN \"newField\" TEXT;\n\n-- Reverse is:\nALTER TABLE \"User\" DROP COLUMN \"newField\";\n\n-- Apply reverse\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c 'ALTER TABLE \"User\" DROP COLUMN \"newField\";'\n

Solution 3: Restore from backup

# If you have backup before migration\ndocker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup-before-migration.sql\n\n# Then mark migration as rolled back\ndocker compose exec api npx prisma migrate resolve --rolled-back \"20260201000000_migration_name\"\n

Solution 4: Fix forward

Instead of rolling back, fix the issue and continue:

# Fix the issue (e.g., add missing default value)\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c 'ALTER TABLE \"User\" ALTER COLUMN \"newField\" SET DEFAULT '\\''value'\\'';'\n\n# Retry migration\ndocker compose exec api npx prisma migrate deploy\n

Solution 5: Baseline from current state

If database is in unknown state:

# Create new migration from current state\ndocker compose exec api npx prisma migrate dev --name baseline --create-only\n\n# Review generated migration\n# If it looks correct, apply:\ndocker compose exec api npx prisma migrate deploy\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_6","title":"Prevention","text":"

Prisma Doesn't Auto-Rollback

Prisma Migrate does NOT automatically rollback failed migrations. You must manually fix issues.

"},{"location":"v2/troubleshooting/database-issues/#query-performance","title":"Query Performance","text":""},{"location":"v2/troubleshooting/database-issues/#slow-queries","title":"Slow Queries","text":"

Severity: \ud83d\udfe1 Medium to \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/database-issues/#symptoms_7","title":"Symptoms","text":"

API requests taking seconds to respond:

GET /api/users - 5000ms\n

Database logs show slow queries:

LOG: duration: 4521.234 ms statement: SELECT * FROM \"User\" WHERE ...\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_7","title":"Common Causes","text":"
  1. Missing indexes - Querying without index
  2. Full table scan - WHERE clause doesn't use index
  3. N+1 queries - Multiple queries instead of JOIN
  4. Large result set - Fetching too many rows
  5. Complex query - Too many JOINs or subqueries
"},{"location":"v2/troubleshooting/database-issues/#solutions_7","title":"Solutions","text":"

Solution 1: Enable slow query logging

In docker-compose.yml:

v2-postgres:\n  command: postgres -c log_min_duration_statement=1000\n  # Logs queries taking > 1 second\n

Restart:

docker compose up -d v2-postgres\n\n# View slow query log\ndocker compose logs v2-postgres | grep \"duration:\"\n

Solution 2: Analyze query

-- Use EXPLAIN to see query plan\nEXPLAIN ANALYZE\nSELECT * FROM \"User\"\nWHERE email LIKE '%@example.com%';\n\n-- Output shows:\n-- Seq Scan on \"User\"  (cost=0.00..20.00 rows=1000 width=100) (actual time=0.123..5.234 rows=50 loops=1)\n--   Filter: (email ~~ '%@example.com%'::text)\n--   Rows Removed by Filter: 950\n-- Planning Time: 0.456 ms\n-- Execution Time: 5.678 ms\n\n-- \"Seq Scan\" = full table scan (slow)\n-- \"Index Scan\" = using index (fast)\n

Solution 3: Add indexes

// In prisma/schema.prisma\nmodel User {\n  id    String @id @default(uuid())\n  email String @unique  // Creates index automatically\n  name  String\n\n  @@index([name])  // Add index for name searches\n}\n

Create migration:

docker compose exec api npx prisma migrate dev --name add_user_name_index\n

Verify index used:

EXPLAIN SELECT * FROM \"User\" WHERE name = 'John';\n-- Should show: Index Scan using User_name_idx\n

Solution 4: Fix N+1 queries

// Bad - N+1 queries\nconst campaigns = await prisma.campaign.findMany();\nfor (const campaign of campaigns) {\n  const emails = await prisma.campaignEmail.findMany({\n    where: { campaignId: campaign.id }\n  });\n}\n// 1 query for campaigns + N queries for emails = N+1\n\n// Good - single query with include\nconst campaigns = await prisma.campaign.findMany({\n  include: {\n    emails: true\n  }\n});\n// 1 query total\n

Solution 5: Limit result size

// Bad - fetch all users\nconst users = await prisma.user.findMany();\n\n// Good - paginate\nconst users = await prisma.user.findMany({\n  take: 50,  // Limit to 50 rows\n  skip: page * 50,  // Offset for pagination\n});\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_7","title":"Prevention","text":""},{"location":"v2/troubleshooting/database-issues/#missing-indexes","title":"Missing Indexes","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/database-issues/#symptoms_8","title":"Symptoms","text":"

Slow queries on filtered/sorted columns:

SELECT * FROM \"Location\" WHERE \"postalCode\" = 'M5H 2N2';\n-- Slow without index on postalCode\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_8","title":"Common Causes","text":"
  1. No index on filter column - WHERE clause column not indexed
  2. No index on sort column - ORDER BY column not indexed
  3. No index on foreign key - JOIN column not indexed
  4. Composite index needed - Multiple columns in WHERE
"},{"location":"v2/troubleshooting/database-issues/#solutions_8","title":"Solutions","text":"

Solution 1: Identify missing indexes

-- Find tables without indexes\nSELECT schemaname, tablename, indexname\nFROM pg_indexes\nWHERE schemaname = 'public'\nORDER BY tablename;\n\n-- Find columns used in WHERE but not indexed\n-- (requires pg_stat_statements extension)\n

Solution 2: Add single-column index

model Location {\n  id         String @id @default(uuid())\n  address    String\n  postalCode String\n\n  @@index([postalCode])  // Add index\n}\n

Solution 3: Add composite index

For queries filtering on multiple columns:

model Location {\n  id         String @id @default(uuid())\n  province   String\n  city       String\n  postalCode String\n\n  @@index([province, city])  // Composite index\n  // Speeds up: WHERE province = 'ON' AND city = 'Toronto'\n  // Also speeds up: WHERE province = 'ON'\n  // Does NOT speed up: WHERE city = 'Toronto' (must start with first column)\n}\n

Solution 4: Add index on foreign key

model CampaignEmail {\n  id         String @id @default(uuid())\n  campaignId String\n\n  campaign Campaign @relation(fields: [campaignId], references: [id])\n\n  @@index([campaignId])  // Index foreign key for JOINs\n}\n

Solution 5: Create migration

# Generate migration for index\ndocker compose exec api npx prisma migrate dev --name add_indexes\n\n# Apply to production\ndocker compose exec api npx prisma migrate deploy\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_8","title":"Prevention","text":"

Index Guidelines

"},{"location":"v2/troubleshooting/database-issues/#n1-queries","title":"N+1 Queries","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/database-issues/#symptoms_9","title":"Symptoms","text":"

API slow when fetching related data:

GET /api/campaigns - 2000ms\n

Database logs show many similar queries:

SELECT * FROM \"CampaignEmail\" WHERE \"campaignId\" = 'uuid1'\nSELECT * FROM \"CampaignEmail\" WHERE \"campaignId\" = 'uuid2'\nSELECT * FROM \"CampaignEmail\" WHERE \"campaignId\" = 'uuid3'\n...\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_9","title":"Common Causes","text":"
  1. No eager loading - Fetching relations in loop
  2. Separate queries - Not using include/select
  3. Nested loops - Multiple levels of relations
"},{"location":"v2/troubleshooting/database-issues/#solutions_9","title":"Solutions","text":"

Solution 1: Detect N+1 queries

Enable query logging:

// In api/src/config/database.ts\nexport const prisma = new PrismaClient({\n  log: ['query'],  // Log all queries\n});\n

Look for repeated patterns:

Query: SELECT * FROM \"Campaign\"\nQuery: SELECT * FROM \"CampaignEmail\" WHERE \"campaignId\" = '...'\nQuery: SELECT * FROM \"CampaignEmail\" WHERE \"campaignId\" = '...'\nQuery: SELECT * FROM \"CampaignEmail\" WHERE \"campaignId\" = '...'\n

Solution 2: Use include

// Bad - N+1\nconst campaigns = await prisma.campaign.findMany();\nfor (const campaign of campaigns) {\n  campaign.emails = await prisma.campaignEmail.findMany({\n    where: { campaignId: campaign.id }\n  });\n}\n// 1 + N queries\n\n// Good - single query\nconst campaigns = await prisma.campaign.findMany({\n  include: {\n    emails: true\n  }\n});\n// 2 queries (1 for campaigns, 1 for all emails with JOIN)\n

Solution 3: Nested includes

// Multi-level relations\nconst campaigns = await prisma.campaign.findMany({\n  include: {\n    emails: {\n      include: {\n        user: true  // Include user who sent email\n      }\n    },\n    createdBy: true\n  }\n});\n

Solution 4: Select only needed fields

// Fetch only needed data\nconst campaigns = await prisma.campaign.findMany({\n  select: {\n    id: true,\n    name: true,\n    emails: {\n      select: {\n        id: true,\n        sentAt: true\n      }\n    }\n  }\n});\n

Solution 5: Use findUnique with include for single record

// Bad\nconst campaign = await prisma.campaign.findUnique({\n  where: { id }\n});\nconst emails = await prisma.campaignEmail.findMany({\n  where: { campaignId: id }\n});\n\n// Good\nconst campaign = await prisma.campaign.findUnique({\n  where: { id },\n  include: { emails: true }\n});\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_9","title":"Prevention","text":""},{"location":"v2/troubleshooting/database-issues/#connection-pool-exhaustion","title":"Connection Pool Exhaustion","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/database-issues/#symptoms_10","title":"Symptoms","text":"
Error: Timed out fetching a new connection from the connection pool.\n

Or:

Error: Can't create connection pool - all connections are in use\n

API becomes unresponsive.

"},{"location":"v2/troubleshooting/database-issues/#common-causes_10","title":"Common Causes","text":"
  1. Pool too small - Not enough connections for load
  2. Connections not released - Long-running transactions
  3. Too many workers - BullMQ workers using all connections
  4. Connection leak - Connections never closed
"},{"location":"v2/troubleshooting/database-issues/#solutions_10","title":"Solutions","text":"

Solution 1: Check pool size

# View DATABASE_URL\ncat .env | grep DATABASE_URL\n\n# Default connection_limit is 10\n# Check if you've set it:\npostgresql://user:pass@host:5432/db?connection_limit=10\n

Solution 2: Increase pool size

# In .env\nDATABASE_URL=\"postgresql://changemaker:password@v2-postgres:5432/changemaker_v2?connection_limit=20\"\n\n# Restart API\ndocker compose restart api\n

Solution 3: Check active connections

-- View connection pool usage\nSELECT count(*), state\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2'\nGROUP BY state;\n\n-- Should show:\n-- count | state\n--    5  | active\n--    2  | idle\n--    3  | idle in transaction\n

Solution 4: Find long-running transactions

-- Find transactions running > 1 minute\nSELECT pid, usename, state, NOW() - xact_start AS duration, query\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2'\n  AND state = 'idle in transaction'\n  AND NOW() - xact_start > INTERVAL '1 minute';\n\n-- Kill if stuck\nSELECT pg_terminate_backend(pid);\n

Solution 5: Configure pool timeout

# Increase timeout from 10s to 30s\nDATABASE_URL=\"postgresql://...?connection_limit=20&pool_timeout=30\"\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_10","title":"Prevention","text":"

Pool Sizing

Recommended pool size = (CPU cores \u00d7 2) + effective_spindle_count

For most applications: 10-20 connections per API instance

"},{"location":"v2/troubleshooting/database-issues/#data-issues","title":"Data Issues","text":""},{"location":"v2/troubleshooting/database-issues/#duplicate-records","title":"Duplicate Records","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/database-issues/#symptoms_11","title":"Symptoms","text":"
Error: Unique constraint failed on the fields: (`email`)\n

Or finding multiple records:

SELECT email, count(*)\nFROM \"User\"\nGROUP BY email\nHAVING count(*) > 1;\n-- Returns duplicates\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_11","title":"Common Causes","text":"
  1. Race condition - Two creates at exact same time
  2. Import error - CSV import created duplicates
  3. Migration bug - Migration didn't handle duplicates
  4. No unique constraint - Database allows duplicates
"},{"location":"v2/troubleshooting/database-issues/#solutions_11","title":"Solutions","text":"

Solution 1: Find duplicates

-- Find duplicate emails\nSELECT email, array_agg(id) AS ids, count(*)\nFROM \"User\"\nGROUP BY email\nHAVING count(*) > 1;\n\n-- Example output:\n-- email              | ids                                    | count\n-- john@example.com   | {uuid1, uuid2}                        | 2\n

Solution 2: Delete duplicates (keep oldest)

-- Delete newer duplicates, keep oldest\nDELETE FROM \"User\" u1\nWHERE EXISTS (\n  SELECT 1 FROM \"User\" u2\n  WHERE u2.email = u1.email\n    AND u2.\"createdAt\" < u1.\"createdAt\"\n);\n\n-- Or keep newest:\nDELETE FROM \"User\" u1\nWHERE EXISTS (\n  SELECT 1 FROM \"User\" u2\n  WHERE u2.email = u1.email\n    AND u2.\"createdAt\" > u1.\"createdAt\"\n);\n

Solution 3: Merge duplicates

-- If duplicates have different data, merge:\n-- 1. Update foreign keys to point to kept record\nUPDATE \"Campaign\" SET \"createdByUserId\" = 'uuid-to-keep'\nWHERE \"createdByUserId\" = 'uuid-to-delete';\n\n-- 2. Delete duplicate\nDELETE FROM \"User\" WHERE id = 'uuid-to-delete';\n

Solution 4: Add unique constraint

model User {\n  id    String @id @default(uuid())\n  email String @unique  // Ensures uniqueness\n}\n

Create migration:

# This will fail if duplicates exist\n# Delete duplicates first (Solution 2)\ndocker compose exec api npx prisma migrate dev --name add_unique_email\n

Solution 5: Prevent in application code

// Use upsert instead of create\nconst user = await prisma.user.upsert({\n  where: { email },\n  update: {},  // Don't change if exists\n  create: { email, name, password }\n});\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_11","title":"Prevention","text":""},{"location":"v2/troubleshooting/database-issues/#constraint-violations","title":"Constraint Violations","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/database-issues/#symptoms_12","title":"Symptoms","text":"
Error: Foreign key constraint failed on the field: `campaignId`\n

Or:

Error: Null value in column \"name\" violates not-null constraint\n

Or:

Error: Check constraint \"positive_age\" is violated\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_12","title":"Common Causes","text":"
  1. Foreign key missing - Referenced record doesn't exist
  2. Null in required field - NULL when NOT NULL constraint
  3. Check constraint - Value violates CHECK constraint
  4. Data type mismatch - Wrong type for column
"},{"location":"v2/troubleshooting/database-issues/#solutions_12","title":"Solutions","text":"

Solution 1: Verify foreign key exists

-- Check if campaign exists\nSELECT id FROM \"Campaign\" WHERE id = 'campaign-uuid';\n\n-- If not found, create parent first\n

Solution 2: Provide required fields

// Bad - missing required field\nawait prisma.user.create({\n  data: {\n    email: 'user@example.com'\n    // Missing: name (required)\n  }\n});\n\n// Good - all required fields\nawait prisma.user.create({\n  data: {\n    email: 'user@example.com',\n    name: 'User Name',\n    password: 'hashed-password'\n  }\n});\n

Solution 3: Handle check constraints

-- If schema has:\nALTER TABLE \"User\" ADD CONSTRAINT age_check CHECK (age >= 0);\n\n-- Ensure value meets constraint:\nINSERT INTO \"User\" (email, age) VALUES ('user@example.com', 25);\n-- Not: VALUES ('user@example.com', -5);\n

Solution 4: Fix data type

// Bad - passing string for number\nawait prisma.location.create({\n  data: {\n    latitude: \"43.65\" as any  // Wrong type\n  }\n});\n\n// Good - use number\nawait prisma.location.create({\n  data: {\n    latitude: 43.65  // Correct type\n  }\n});\n

Solution 5: Use transactions for dependent creates

// Create parent and child atomically\nawait prisma.$transaction(async (tx) => {\n  const campaign = await tx.campaign.create({\n    data: { name: 'My Campaign' }\n  });\n\n  const email = await tx.campaignEmail.create({\n    data: {\n      campaignId: campaign.id,\n      subject: 'Email Subject'\n    }\n  });\n});\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_12","title":"Prevention","text":""},{"location":"v2/troubleshooting/database-issues/#data-corruption","title":"Data Corruption","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/database-issues/#symptoms_13","title":"Symptoms","text":"
SELECT * FROM \"Campaign\" WHERE \"settings\"::text LIKE '%\\\\u0000%';\n-- Null bytes in JSON\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_13","title":"Common Causes","text":"
  1. Bad import - CSV/JSON import with bad data
  2. Encoding issues - Wrong character encoding
  3. Failed migration - Migration partially applied
  4. Application bug - Code writing bad data
"},{"location":"v2/troubleshooting/database-issues/#solutions_13","title":"Solutions","text":"

Solution 1: Detect corruption

-- Find invalid JSON\nSELECT id, settings\nFROM \"Campaign\"\nWHERE settings IS NOT NULL\n  AND settings::text !~ '^[\\[\\{].*[\\]\\}]$';\n\n-- Find null bytes\nSELECT id, name\nFROM \"Location\"\nWHERE name LIKE '%' || chr(0) || '%';\n\n-- Find wrong encoding\nSELECT id, address\nFROM \"Location\"\nWHERE address ~ '[^\\x00-\\x7F]' AND address !~ '[\u00c0-\u00ff]';\n

Solution 2: Fix invalid JSON

-- Replace invalid JSON with valid default\nUPDATE \"Campaign\"\nSET settings = '{}'::jsonb\nWHERE settings IS NOT NULL\n  AND settings::text !~ '^[\\[\\{].*[\\]\\}]$';\n

Solution 3: Fix encoding

-- Convert encoding\nUPDATE \"Location\"\nSET address = convert_from(convert_to(address, 'LATIN1'), 'UTF8')\nWHERE address ~ '[^\\x00-\\x7F]';\n

Solution 4: Restore from backup

# If corruption is widespread, restore from backup\ndocker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup-before-corruption.sql\n

Solution 5: Prevent future corruption

// Validate data before saving\nimport { z } from 'zod';\n\nconst settingsSchema = z.object({\n  key: z.string(),\n  value: z.any()\n});\n\n// Before save\nconst validated = settingsSchema.parse(settings);\nawait prisma.campaign.update({\n  where: { id },\n  data: { settings: validated as any }\n});\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_13","title":"Prevention","text":""},{"location":"v2/troubleshooting/database-issues/#prisma-studio-issues","title":"Prisma Studio Issues","text":""},{"location":"v2/troubleshooting/database-issues/#wont-connect","title":"Won't Connect","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/database-issues/#symptoms_14","title":"Symptoms","text":"
docker compose exec api npx prisma studio\n

Opens browser but shows:

Error connecting to database\n
"},{"location":"v2/troubleshooting/database-issues/#solutions_14","title":"Solutions","text":"

Solution 1: Check DATABASE_URL

# Verify DATABASE_URL in container\ndocker compose exec api sh -c 'echo $DATABASE_URL'\n\n# Should be valid connection string\n

Solution 2: Test connection

# Test database connection\ndocker compose exec api npx prisma db pull\n\n# If fails, connection string is wrong\n

Solution 3: Use correct port

Prisma Studio runs on port 5555 by default. If port conflicts:

# Use different port\ndocker compose exec api npx prisma studio --port 5556\n

Solution 4: Check database is running

docker compose ps v2-postgres\n# Must be \"Up\"\n
"},{"location":"v2/troubleshooting/database-issues/#slow-loading","title":"Slow Loading","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/database-issues/#symptoms_15","title":"Symptoms","text":"

Prisma Studio takes minutes to load tables with many rows.

"},{"location":"v2/troubleshooting/database-issues/#solutions_15","title":"Solutions","text":"

Solution 1: Limit rows

Prisma Studio loads all rows. For large tables, use SQL instead:

# Instead of Prisma Studio for large tables\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2\n

Solution 2: Add pagination

-- In psql, paginate manually\nSELECT * FROM \"Location\" LIMIT 50 OFFSET 0;\nSELECT * FROM \"Location\" LIMIT 50 OFFSET 50;\n
"},{"location":"v2/troubleshooting/database-issues/#drizzle-kit-issues","title":"Drizzle Kit Issues","text":""},{"location":"v2/troubleshooting/database-issues/#push-failures","title":"Push Failures","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/database-issues/#symptoms_16","title":"Symptoms","text":"
docker compose exec api npx drizzle-kit push\n

Fails with:

Error: Failed to push schema changes\n
"},{"location":"v2/troubleshooting/database-issues/#solutions_16","title":"Solutions","text":"

Solution 1: Check Drizzle config

// In api/drizzle.config.ts\nimport { defineConfig } from 'drizzle-kit';\n\nexport default defineConfig({\n  schema: './src/modules/media/db/schema.ts',\n  out: './drizzle',\n  dialect: 'postgresql',\n  dbCredentials: {\n    url: process.env.DATABASE_URL!\n  }\n});\n

Solution 2: Verify schema file

# Check schema file exists\ndocker compose exec api ls -la src/modules/media/db/schema.ts\n\n# Check for syntax errors\ndocker compose exec api npx tsc --noEmit src/modules/media/db/schema.ts\n

Solution 3: Check for conflicts with Prisma tables

Drizzle and Prisma share same database. Ensure table names don't conflict:

// Drizzle tables\nexport const videos = pgTable('media_videos', { ... });\nexport const reactions = pgTable('media_reactions', { ... });\n\n// Prisma uses: User, Campaign, etc. (no conflict)\n

Solution 4: Manually apply schema

# Generate SQL\ndocker compose exec api npx drizzle-kit generate:pg\n\n# Review SQL in drizzle/ directory\n# Apply manually if needed\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 < drizzle/0000_schema.sql\n
"},{"location":"v2/troubleshooting/database-issues/#backuprestore-issues","title":"Backup/Restore Issues","text":""},{"location":"v2/troubleshooting/database-issues/#pg_dump-errors","title":"pg_dump Errors","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/database-issues/#symptoms_17","title":"Symptoms","text":"
docker compose exec v2-postgres pg_dump -U changemaker changemaker_v2 > backup.sql\n

Fails with:

pg_dump: error: connection to server on socket \"/var/run/postgresql/.s.PGSQL.5432\" failed: No such file or directory\n
"},{"location":"v2/troubleshooting/database-issues/#solutions_17","title":"Solutions","text":"

Solution 1: Use correct connection

# From inside container\ndocker compose exec v2-postgres pg_dump -U changemaker changemaker_v2 > backup.sql\n\n# Or specify host explicitly\ndocker compose exec v2-postgres pg_dump -U changemaker -h v2-postgres changemaker_v2 > backup.sql\n

Solution 2: Backup to file inside container

# Dump to file inside container\ndocker compose exec v2-postgres pg_dump -U changemaker changemaker_v2 -f /tmp/backup.sql\n\n# Copy to host\ndocker cp changemaker-lite-v2-postgres-1:/tmp/backup.sql ./backup.sql\n

Solution 3: Use backup script

# Use provided backup script\n./scripts/backup.sh\n
"},{"location":"v2/troubleshooting/database-issues/#restore-failures","title":"Restore Failures","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/database-issues/#symptoms_18","title":"Symptoms","text":"
docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup.sql\n

Fails with errors:

ERROR: relation \"User\" already exists\nERROR: duplicate key value violates unique constraint\n
"},{"location":"v2/troubleshooting/database-issues/#solutions_18","title":"Solutions","text":"

Solution 1: Drop database first

# \u26a0\ufe0f DELETES ALL DATA!\ndocker compose exec v2-postgres psql -U postgres -c \"DROP DATABASE changemaker_v2;\"\ndocker compose exec v2-postgres psql -U postgres -c \"CREATE DATABASE changemaker_v2 OWNER changemaker;\"\n\n# Then restore\ndocker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup.sql\n

Solution 2: Use --clean flag

# Create backup with clean option\ndocker compose exec v2-postgres pg_dump -U changemaker --clean changemaker_v2 > backup.sql\n\n# Restore (drops existing objects first)\ndocker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup.sql\n

Solution 3: Ignore errors for existing objects

# Restore and ignore \"already exists\" errors\ndocker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup.sql 2>&1 | grep -v \"already exists\"\n
"},{"location":"v2/troubleshooting/database-issues/#useful-commands","title":"Useful Commands","text":""},{"location":"v2/troubleshooting/database-issues/#query-database","title":"Query Database","text":"
# Connect to database\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2\n\n# Run single query\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"SELECT NOW();\"\n\n# Run SQL file\ndocker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < script.sql\n\n# Export query results to CSV\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"COPY (SELECT * FROM \\\"User\\\") TO STDOUT WITH CSV HEADER\" > users.csv\n
"},{"location":"v2/troubleshooting/database-issues/#database-inspection","title":"Database Inspection","text":"
# List databases\ndocker compose exec v2-postgres psql -U postgres -l\n\n# List tables\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"\\dt\"\n\n# Describe table\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"\\d \\\"User\\\"\"\n\n# List indexes\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"\\di\"\n\n# View table sizes\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"\nSELECT\n  schemaname,\n  tablename,\n  pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size\nFROM pg_tables\nWHERE schemaname = 'public'\nORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;\n\"\n
"},{"location":"v2/troubleshooting/database-issues/#performance-analysis","title":"Performance Analysis","text":"
# Current activity\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"\nSELECT pid, usename, application_name, state, query_start, query\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2'\nORDER BY query_start;\n\"\n\n# Table statistics\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"\nSELECT schemaname, tablename, n_live_tup, n_dead_tup, last_autovacuum\nFROM pg_stat_user_tables\nORDER BY n_live_tup DESC;\n\"\n\n# Index usage\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"\nSELECT schemaname, tablename, indexname, idx_scan, idx_tup_read, idx_tup_fetch\nFROM pg_stat_user_indexes\nORDER BY idx_scan DESC;\n\"\n\n# Unused indexes\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"\nSELECT schemaname, tablename, indexname, idx_scan\nFROM pg_stat_user_indexes\nWHERE idx_scan = 0 AND indexname NOT LIKE '%pkey'\nORDER BY pg_relation_size(indexname::regclass) DESC;\n\"\n
"},{"location":"v2/troubleshooting/database-issues/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/database-issues/#database-documentation","title":"Database Documentation","text":""},{"location":"v2/troubleshooting/database-issues/#other-troubleshooting","title":"Other Troubleshooting","text":""},{"location":"v2/troubleshooting/database-issues/#postgresql-resources","title":"PostgreSQL Resources","text":"

Last Updated: February 2026 Version: V2.0 Status: Complete

"},{"location":"v2/troubleshooting/docker-issues/","title":"Docker and Container Issues","text":"

This guide covers Docker-specific problems in Changemaker Lite V2.

"},{"location":"v2/troubleshooting/docker-issues/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/docker-issues/#docker-troubleshooting-approach","title":"Docker Troubleshooting Approach","text":"
  1. Check status - Are containers running?
  2. Read logs - What do container logs show?
  3. Inspect configuration - Is docker-compose.yml correct?
  4. Test connectivity - Can containers communicate?
  5. Resource check - Enough CPU/memory/disk?
"},{"location":"v2/troubleshooting/docker-issues/#essential-docker-commands","title":"Essential Docker Commands","text":"
# View running containers\ndocker compose ps\n\n# View all containers (including stopped)\ndocker compose ps -a\n\n# View logs\ndocker compose logs [service-name]\n\n# Follow logs in real-time\ndocker compose logs -f [service-name]\n\n# Execute command in container\ndocker compose exec [service-name] [command]\n\n# Restart service\ndocker compose restart [service-name]\n\n# Stop all services\ndocker compose down\n\n# Start services\ndocker compose up -d\n\n# Rebuild and start\ndocker compose up -d --build [service-name]\n
"},{"location":"v2/troubleshooting/docker-issues/#container-wont-start","title":"Container Won't Start","text":""},{"location":"v2/troubleshooting/docker-issues/#port-already-in-use","title":"Port Already in Use","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/docker-issues/#symptoms","title":"Symptoms","text":"
Error response from daemon: driver failed programming external connectivity\non endpoint changemaker-lite-admin-1: Bind for 0.0.0.0:3000 failed:\nport is already allocated\n

Or:

ERROR: for api  Cannot start service api: Ports are not available:\nexposing port TCP 0.0.0.0:4000 -> 0.0.0.0:0: listen tcp 0.0.0.0:4000:\nbind: address already in use\n
"},{"location":"v2/troubleshooting/docker-issues/#common-causes","title":"Common Causes","text":"
  1. Another container using port - Different Docker project
  2. Host process using port - npm dev server running
  3. Previous container not stopped - Old container still running
  4. Port conflict in docker-compose.yml - Two services same port
"},{"location":"v2/troubleshooting/docker-issues/#solutions","title":"Solutions","text":"

Solution 1: Find what's using the port

# Linux/Mac\nsudo lsof -i :4000\n\n# Or with netstat\nnetstat -tuln | grep :4000\n\n# Windows\nnetstat -ano | findstr :4000\n

Output shows:

COMMAND   PID  USER   FD   TYPE DEVICE SIZE/OFF NODE NAME\nnode    12345  user   23u  IPv4 123456      0t0  TCP *:4000 (LISTEN)\n

Solution 2: Stop conflicting process

# Kill process by PID\nkill 12345\n\n# Or kill all node processes (careful!)\nkillall node\n\n# Or stop other Docker containers\ndocker ps  # List all running containers\ndocker stop container-name-or-id\n

Solution 3: Change port in docker-compose.yml

# In docker-compose.yml\napi:\n  ports:\n    - \"4002:4000\"  # Changed from 4000:4000\n

Then:

# Restart with new port\ndocker compose up -d api\n\n# Update .env to use new port\nVITE_API_URL=http://localhost:4002\n

Solution 4: Stop all and restart

# Stop all Changemaker Lite containers\ndocker compose down\n\n# Verify nothing running\ndocker compose ps\n\n# Start fresh\ndocker compose up -d\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention","title":"Prevention","text":""},{"location":"v2/troubleshooting/docker-issues/#volume-mount-errors","title":"Volume Mount Errors","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_1","title":"Symptoms","text":"
Error response from daemon: invalid mount config for type \"bind\":\nbind source path does not exist: /home/user/changemaker.lite/uploads\n

Or:

Error: EACCES: permission denied, open '/media/local/inbox/video.mp4'\n
"},{"location":"v2/troubleshooting/docker-issues/#common-causes_1","title":"Common Causes","text":"
  1. Path doesn't exist - Directory not created
  2. Permission denied - Container can't access directory
  3. Wrong path - Typo in docker-compose.yml
  4. SELinux blocking - Linux security policy
"},{"location":"v2/troubleshooting/docker-issues/#solutions_1","title":"Solutions","text":"

Solution 1: Create missing directories

# Create all required directories\nmkdir -p uploads\nmkdir -p media/local/inbox\nmkdir -p media/local/library\nmkdir -p data\nmkdir -p configs/prometheus\nmkdir -p configs/grafana\n\n# Verify they exist\nls -la\n

Solution 2: Fix permissions

# Make directories writable\nchmod -R 777 uploads\nchmod -R 777 media/local/inbox\n\n# Or set ownership to container user\n# Check container user ID\ndocker compose exec api id\n# uid=1000(node) gid=1000(node)\n\n# Set ownership\nsudo chown -R 1000:1000 uploads\nsudo chown -R 1000:1000 media\n

Solution 3: Check volume configuration

In docker-compose.yml:

api:\n  volumes:\n    # Correct format:\n    - ./uploads:/app/uploads:rw        # Read-write\n    - ./media:/media:ro                 # Read-only\n\n    # Wrong formats:\n    # - uploads:/app/uploads            # Named volume, not bind mount\n    # - /uploads:/app/uploads           # Absolute path on host\n

Solution 4: Disable SELinux (last resort)

# Check if SELinux is the issue\ngetenforce\n# If \"Enforcing\":\n\n# Option 1: Add :z flag to volume\n# In docker-compose.yml:\n    - ./uploads:/app/uploads:z\n\n# Option 2: Temporarily disable (not recommended)\nsudo setenforce 0\n

Solution 5: Verify mount inside container

# Check if mount exists\ndocker compose exec api ls -la /app/uploads\n\n# Check permissions\ndocker compose exec api ls -ld /app/uploads\n\n# Try creating file\ndocker compose exec api touch /app/uploads/test.txt\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_1","title":"Prevention","text":""},{"location":"v2/troubleshooting/docker-issues/#missing-environment-variables","title":"Missing Environment Variables","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_2","title":"Symptoms","text":"

Container logs show:

Error: DATABASE_URL is required\n

Or:

ZodError: [\n  {\n    \"code\": \"invalid_type\",\n    \"expected\": \"string\",\n    \"received\": \"undefined\",\n    \"path\": [\"SMTP_HOST\"],\n    \"message\": \"Required\"\n  }\n]\n

Or container exits immediately:

changemaker-lite-api-1 exited with code 1\n
"},{"location":"v2/troubleshooting/docker-issues/#common-causes_2","title":"Common Causes","text":"
  1. .env not found - Missing .env file
  2. Variable not set - Missing required variable
  3. Wrong .env location - .env not in project root
  4. Syntax error - Malformed .env file
"},{"location":"v2/troubleshooting/docker-issues/#solutions_2","title":"Solutions","text":"

Solution 1: Check .env exists

# Verify .env file\nls -la .env\n\n# If missing, copy from example\ncp .env.example .env\n

Solution 2: Find missing variables

# View container logs to see which variable\ndocker compose logs api | grep -i \"required\\|undefined\"\n\n# Example output:\n# Error: SMTP_HOST is required\n

Solution 3: Add missing variables

# Edit .env\nnano .env\n\n# Add missing variable\nSMTP_HOST=smtp.gmail.com\n\n# Save and restart\ndocker compose restart api\n

Solution 4: Validate .env format

# Check for common issues:\n# - No spaces around =\n# - Quotes for values with spaces\n# - No trailing commas\n# - No comments on same line as value\n\n# Good:\nDATABASE_URL=\"postgresql://user:pass@host:5432/db\"\nCORS_ORIGINS=http://localhost:3000,http://localhost:4000\n\n# Bad:\nDATABASE_URL = \"postgresql://...\"  # Space around =\nCORS_ORIGINS=http://localhost:3000, http://localhost:4000  # Space after comma\nSMTP_HOST=smtp.gmail.com # Gmail  # Comment on same line\n

Solution 5: Check which variables are loaded

# View environment inside container\ndocker compose exec api env | grep -E \"DATABASE_URL|SMTP_HOST|JWT_\"\n\n# Should show actual values (not undefined)\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_2","title":"Prevention","text":""},{"location":"v2/troubleshooting/docker-issues/#health-check-failures","title":"Health Check Failures","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_3","title":"Symptoms","text":"
docker compose ps\n

Shows:

NAME                    STATUS\napi                     Up 30 seconds (unhealthy)\nv2-postgres            Up 1 minute (healthy)\n

Or logs show:

Health check failed\n
"},{"location":"v2/troubleshooting/docker-issues/#common-causes_3","title":"Common Causes","text":"
  1. Service not ready - Still starting up
  2. Health check endpoint failing - /health returns error
  3. Timeout too short - Service needs more time
  4. Dependencies not ready - Database not connected
"},{"location":"v2/troubleshooting/docker-issues/#solutions_3","title":"Solutions","text":"

Solution 1: Check health check configuration

In docker-compose.yml:

api:\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"--quiet\", \"--tries=1\", \"--spider\", \"http://localhost:4000/api/health\"]\n    interval: 30s\n    timeout: 10s\n    retries: 3\n    start_period: 40s\n

Solution 2: Test health endpoint manually

# From inside container\ndocker compose exec api wget -O- http://localhost:4000/api/health\n\n# Should return:\n# {\"status\":\"healthy\",\"timestamp\":\"2026-02-13T...\"}\n\n# From host\ncurl http://localhost:4000/api/health\n

Solution 3: View health check logs

# Detailed health check output\ndocker inspect changemaker-lite-api-1 --format='{{json .State.Health}}' | jq\n\n# Shows:\n# {\n#   \"Status\": \"unhealthy\",\n#   \"FailingStreak\": 3,\n#   \"Log\": [\n#     {\n#       \"Start\": \"2026-02-13T...\",\n#       \"End\": \"2026-02-13T...\",\n#       \"ExitCode\": 1,\n#       \"Output\": \"Error: Connection refused\"\n#     }\n#   ]\n# }\n

Solution 4: Increase timeout/interval

api:\n  healthcheck:\n    interval: 60s      # Check less frequently\n    timeout: 30s       # Allow more time\n    start_period: 90s  # Wait longer before first check\n

Solution 5: Check service logs

# Real issue is usually in service logs\ndocker compose logs api | tail -50\n\n# Common issues:\n# - Database connection failed\n# - Missing environment variable\n# - Port already in use\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_3","title":"Prevention","text":""},{"location":"v2/troubleshooting/docker-issues/#container-crashes","title":"Container Crashes","text":""},{"location":"v2/troubleshooting/docker-issues/#out-of-memory","title":"Out of Memory","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_4","title":"Symptoms","text":"

Container logs show:

<--- Last few GCs --->\n[1:0x5588e4f8e000]    65432 ms: Mark-sweep 2048.0 (2048.4) -> 2047.9 (2048.4) MB, 1845.2 / 0.0 ms  (average mu = 0.123, current mu = 0.001) allocation failure scavenge might not succeed\n\n<--- JS stacktrace --->\nFATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory\n

Or:

Killed\n

Or docker compose ps shows:

api   Exit 137\n
"},{"location":"v2/troubleshooting/docker-issues/#common-causes_4","title":"Common Causes","text":"
  1. Memory leak - Application leaking memory
  2. Large dataset - Processing too much data
  3. Too many connections - Database connection pool too large
  4. Container limit - Memory limit too low
"},{"location":"v2/troubleshooting/docker-issues/#solutions_4","title":"Solutions","text":"

Solution 1: Check memory usage

# View container memory usage\ndocker stats\n\n# Shows:\n# CONTAINER           CPU %    MEM USAGE / LIMIT     MEM %\n# api                 15.5%    1.2GiB / 2GiB        60%\n

Solution 2: Increase Node.js heap size

In docker-compose.yml:

api:\n  environment:\n    - NODE_OPTIONS=--max-old-space-size=4096  # 4GB heap\n

Or in api/package.json:

{\n  \"scripts\": {\n    \"start\": \"node --max-old-space-size=4096 dist/server.js\"\n  }\n}\n

Solution 3: Increase container memory limit

api:\n  deploy:\n    resources:\n      limits:\n        memory: 4G  # Increase from 2G\n      reservations:\n        memory: 2G\n

Solution 4: Find memory leak

# Enable heap snapshots\ndocker compose exec api node --inspect dist/server.js\n\n# Or use clinic.js\nnpm install -g clinic\nclinic doctor -- node dist/server.js\n

Solution 5: Reduce memory usage

// Reduce database connection pool\n// In prisma/schema.prisma\ndatasource db {\n  provider = \"postgresql\"\n  url      = env(\"DATABASE_URL\")\n  // Add connection limit\n}\n\n// In DATABASE_URL:\nDATABASE_URL=\"postgresql://...?connection_limit=5\"\n\n// Process data in batches\nconst users = await prisma.user.findMany({\n  take: 100,  // Limit batch size\n  skip: offset\n});\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_4","title":"Prevention","text":""},{"location":"v2/troubleshooting/docker-issues/#application-errors","title":"Application Errors","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_5","title":"Symptoms","text":"

Container exits immediately:

api-1 exited with code 1\n

Logs show:

Error: Cannot find module 'express'\n

Or:

SyntaxError: Unexpected token 'export'\n
"},{"location":"v2/troubleshooting/docker-issues/#common-causes_5","title":"Common Causes","text":"
  1. Missing dependencies - npm install not run
  2. Build not run - TypeScript not compiled
  3. Syntax error - Code has errors
  4. Wrong Node version - Incompatible Node.js version
"},{"location":"v2/troubleshooting/docker-issues/#solutions_5","title":"Solutions","text":"

Solution 1: Rebuild container

# Rebuild with no cache\ndocker compose build --no-cache api\n\n# Start\ndocker compose up -d api\n\n# View logs\ndocker compose logs -f api\n

Solution 2: Check dependencies

# Verify package.json and package-lock.json exist\ndocker compose exec api ls -la package*.json\n\n# Verify node_modules exists\ndocker compose exec api ls -la node_modules | head\n\n# If missing, install\ndocker compose exec api npm install\n

Solution 3: Verify build

# Check if TypeScript compiled\ndocker compose exec api ls -la dist/\n\n# If missing, build\ndocker compose exec api npm run build\n\n# Or rebuild container\ndocker compose up -d --build api\n

Solution 4: Check Node version

# Check version in container\ndocker compose exec api node --version\n\n# Should match Dockerfile\ncat api/Dockerfile | grep \"FROM node:\"\n\n# Example:\n# FROM node:20-alpine\n

Solution 5: Test locally

# Test build locally\ncd api\nnpm install\nnpm run build\nnpm start\n\n# If works locally but not in Docker, check:\n# - Dockerfile COPY commands\n# - .dockerignore file\n# - Volume mounts\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_5","title":"Prevention","text":""},{"location":"v2/troubleshooting/docker-issues/#database-connection-failures","title":"Database Connection Failures","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_6","title":"Symptoms","text":"

API logs show:

Error: Can't reach database server at `v2-postgres:5432`\nError: connect ECONNREFUSED 172.18.0.2:5432\n

Container restarts repeatedly.

"},{"location":"v2/troubleshooting/docker-issues/#common-causes_6","title":"Common Causes","text":"
  1. Database not ready - API started before database
  2. Wrong host - Incorrect database hostname
  3. Network issue - Containers on different networks
  4. Database crashed - PostgreSQL container down
"},{"location":"v2/troubleshooting/docker-issues/#solutions_6","title":"Solutions","text":"

Solution 1: Check database status

# Is database running?\ndocker compose ps v2-postgres\n\n# Should show \"Up\" status\n# If not:\ndocker compose up -d v2-postgres\n\n# Check logs\ndocker compose logs v2-postgres | tail -50\n

Solution 2: Verify DATABASE_URL

# Check .env\ncat .env | grep DATABASE_URL\n\n# From API container, should use container name:\nDATABASE_URL=\"postgresql://changemaker:password@v2-postgres:5432/changemaker_v2\"\n\n# From host, use localhost:\nDATABASE_URL=\"postgresql://changemaker:password@localhost:5433/changemaker_v2\"\n

Solution 3: Test database connection

# From API container\ndocker compose exec api sh -c 'psql $DATABASE_URL -c \"SELECT NOW();\"'\n\n# Should return current timestamp\n# If fails, database connection is broken\n

Solution 4: Check Docker network

# List networks\ndocker network ls\n\n# Inspect changemaker-lite network\ndocker network inspect changemaker-lite\n\n# All containers should be on same network\n

Solution 5: Use depends_on with health check

In docker-compose.yml:

api:\n  depends_on:\n    v2-postgres:\n      condition: service_healthy\n  # ...\n\nv2-postgres:\n  healthcheck:\n    test: [\"CMD-SHELL\", \"pg_isready -U changemaker\"]\n    interval: 10s\n    timeout: 5s\n    retries: 5\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_6","title":"Prevention","text":""},{"location":"v2/troubleshooting/docker-issues/#networking-issues","title":"Networking Issues","text":""},{"location":"v2/troubleshooting/docker-issues/#containers-cant-communicate","title":"Containers Can't Communicate","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_7","title":"Symptoms","text":"
Error: getaddrinfo ENOTFOUND v2-postgres\n

Or:

Error: connect EHOSTUNREACH 172.18.0.2:5432\n

Containers can't ping each other.

"},{"location":"v2/troubleshooting/docker-issues/#common-causes_7","title":"Common Causes","text":"
  1. Different networks - Containers on separate Docker networks
  2. Wrong hostname - Using IP instead of container name
  3. Firewall - Host firewall blocking
  4. DNS issue - Docker DNS not working
"},{"location":"v2/troubleshooting/docker-issues/#solutions_7","title":"Solutions","text":"

Solution 1: Verify same network

# Check container networks\ndocker inspect changemaker-lite-api-1 | grep NetworkMode\ndocker inspect changemaker-lite-v2-postgres-1 | grep NetworkMode\n\n# Should both show \"changemaker-lite\"\n

Solution 2: Use container names

# Correct - use service names\napi:\n  environment:\n    - DATABASE_URL=postgresql://user:pass@v2-postgres:5432/db\n\n# Wrong - using IPs\napi:\n  environment:\n    - DATABASE_URL=postgresql://user:pass@172.18.0.2:5432/db\n

Solution 3: Test connectivity

# Ping from one container to another\ndocker compose exec api ping v2-postgres\n\n# DNS lookup\ndocker compose exec api nslookup v2-postgres\n\n# Telnet to port\ndocker compose exec api telnet v2-postgres 5432\n

Solution 4: Recreate network

# Stop all containers\ndocker compose down\n\n# Remove network\ndocker network rm changemaker-lite\n\n# Start fresh (network auto-created)\ndocker compose up -d\n

Solution 5: Check firewall

# Temporarily disable firewall (Linux)\nsudo ufw disable\n\n# Test if containers can communicate\n# If yes, firewall is blocking\n\n# Re-enable and add rules\nsudo ufw enable\nsudo ufw allow from 172.18.0.0/16 to any\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_7","title":"Prevention","text":""},{"location":"v2/troubleshooting/docker-issues/#port-not-accessible-from-host","title":"Port Not Accessible from Host","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_8","title":"Symptoms","text":"

From host:

curl http://localhost:4000/api/health\n# curl: (7) Failed to connect to localhost port 4000: Connection refused\n

But from inside container:

docker compose exec api curl http://localhost:4000/api/health\n# {\"status\":\"healthy\"}\n
"},{"location":"v2/troubleshooting/docker-issues/#common-causes_8","title":"Common Causes","text":"
  1. Port not published - Missing ports: in docker-compose.yml
  2. Bound to 127.0.0.1 - Only listening on localhost inside container
  3. Firewall blocking - Host firewall blocking port
  4. Wrong port - Trying different port than published
"},{"location":"v2/troubleshooting/docker-issues/#solutions_8","title":"Solutions","text":"

Solution 1: Check port publishing

In docker-compose.yml:

api:\n  ports:\n    - \"4000:4000\"  # host:container\n

Verify:

docker compose ps api\n\n# Should show:\n# PORTS: 0.0.0.0:4000->4000/tcp\n

Solution 2: Bind to 0.0.0.0

In api/src/server.ts:

// Wrong - only localhost\napp.listen(4000, '127.0.0.1');\n\n// Right - all interfaces\napp.listen(4000, '0.0.0.0');\n\n// Or just\napp.listen(4000);  // Defaults to 0.0.0.0\n

Solution 3: Check firewall

# Check if port allowed (Linux)\nsudo ufw status\n\n# Allow port\nsudo ufw allow 4000/tcp\n\n# Or disable temporarily for testing\nsudo ufw disable\n

Solution 4: Verify correct port

# Check what ports are actually listening\ndocker compose exec api netstat -tuln\n\n# Should show:\n# tcp6  0  0  :::4000  :::*  LISTEN\n

Solution 5: Restart with port forwarding

# Stop container\ndocker compose stop api\n\n# Remove container\ndocker compose rm -f api\n\n# Start fresh\ndocker compose up -d api\n\n# Verify port\ncurl http://localhost:4000/api/health\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_8","title":"Prevention","text":""},{"location":"v2/troubleshooting/docker-issues/#dns-resolution-failures","title":"DNS Resolution Failures","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_9","title":"Symptoms","text":"
Error: getaddrinfo ENOTFOUND smtp.gmail.com\n

Or:

Error: getaddrinfo EAI_AGAIN api.represent.org\n

Container can't resolve external hostnames.

"},{"location":"v2/troubleshooting/docker-issues/#common-causes_9","title":"Common Causes","text":"
  1. Docker DNS issue - Docker DNS not working
  2. No internet - Container has no internet access
  3. Firewall blocking DNS - Port 53 blocked
  4. Wrong DNS servers - Using invalid DNS servers
"},{"location":"v2/troubleshooting/docker-issues/#solutions_9","title":"Solutions","text":"

Solution 1: Test DNS resolution

# From inside container\ndocker compose exec api nslookup google.com\n\n# Should return IP address\n# If not, DNS is broken\n

Solution 2: Check Docker DNS

# View container DNS config\ndocker compose exec api cat /etc/resolv.conf\n\n# Should show:\n# nameserver 127.0.0.11  # Docker's embedded DNS\n

Solution 3: Use custom DNS servers

In docker-compose.yml:

api:\n  dns:\n    - 8.8.8.8      # Google DNS\n    - 8.8.4.4\n

Or in /etc/docker/daemon.json:

{\n  \"dns\": [\"8.8.8.8\", \"8.8.4.4\"]\n}\n

Then restart Docker:

sudo systemctl restart docker\n

Solution 4: Check internet connectivity

# Ping external host\ndocker compose exec api ping -c 3 8.8.8.8\n\n# If fails, no internet access\n# Check host internet connection\nping -c 3 8.8.8.8\n

Solution 5: Restart Docker daemon

# Sometimes Docker DNS gets stuck\nsudo systemctl restart docker\n\n# Then restart containers\ndocker compose down\ndocker compose up -d\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_9","title":"Prevention","text":""},{"location":"v2/troubleshooting/docker-issues/#volume-issues","title":"Volume Issues","text":""},{"location":"v2/troubleshooting/docker-issues/#permission-denied","title":"Permission Denied","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_10","title":"Symptoms","text":"
Error: EACCES: permission denied, open '/app/uploads/image.jpg'\n

Or:

Error: EACCES: permission denied, mkdir '/media/local/inbox'\n

File operations fail inside container.

"},{"location":"v2/troubleshooting/docker-issues/#common-causes_10","title":"Common Causes","text":"
  1. Wrong ownership - Host directory owned by different user
  2. Wrong permissions - Directory not writable
  3. SELinux - Linux security policy blocking
  4. Read-only mount - Volume mounted as read-only
"},{"location":"v2/troubleshooting/docker-issues/#solutions_10","title":"Solutions","text":"

Solution 1: Check ownership

# On host\nls -la uploads/\n\n# Shows:\n# drwxr-xr-x  2 root   root   4096 Feb 13 10:00 uploads\n\n# Check container user\ndocker compose exec api id\n# uid=1000(node) gid=1000(node)\n\n# Fix ownership\nsudo chown -R 1000:1000 uploads/\n

Solution 2: Fix permissions

# Make writable\nchmod -R 755 uploads/\n\n# Or more permissive (dev only)\nchmod -R 777 uploads/\n

Solution 3: Check mount mode

In docker-compose.yml:

api:\n  volumes:\n    - ./uploads:/app/uploads:rw  # Read-write\n    # Not:\n    # - ./uploads:/app/uploads:ro  # Read-only\n

Solution 4: SELinux labels

# Add :z flag to volume\n# In docker-compose.yml:\n    - ./uploads:/app/uploads:z\n\n# Or relabel directory\nsudo chcon -Rt svirt_sandbox_file_t uploads/\n

Solution 5: Run as root (not recommended)

# In docker-compose.yml (last resort)\napi:\n  user: \"0:0\"  # Run as root\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_10","title":"Prevention","text":""},{"location":"v2/troubleshooting/docker-issues/#volume-not-mounted","title":"Volume Not Mounted","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_11","title":"Symptoms","text":"

Container can't see files that exist on host.

# On host\nls uploads/\n# image.jpg  video.mp4\n\n# In container\ndocker compose exec api ls /app/uploads/\n# (empty)\n
"},{"location":"v2/troubleshooting/docker-issues/#common-causes_11","title":"Common Causes","text":"
  1. Wrong path - Volume path incorrect
  2. Typo - Syntax error in docker-compose.yml
  3. Not mounted - Volume mount missing
  4. Cached old config - Using old container
"},{"location":"v2/troubleshooting/docker-issues/#solutions_11","title":"Solutions","text":"

Solution 1: Verify volume configuration

In docker-compose.yml:

api:\n  volumes:\n    - ./uploads:/app/uploads  # host:container\n

Solution 2: Check mounts in running container

# Inspect container mounts\ndocker inspect changemaker-lite-api-1 | grep -A 10 Mounts\n\n# Should show:\n# \"Mounts\": [\n#   {\n#     \"Type\": \"bind\",\n#     \"Source\": \"/home/user/changemaker.lite/uploads\",\n#     \"Destination\": \"/app/uploads\",\n#     \"Mode\": \"\",\n#     \"RW\": true,\n#     \"Propagation\": \"rprivate\"\n#   }\n# ]\n

Solution 3: Recreate container

# Stop and remove container\ndocker compose down api\n\n# Start fresh\ndocker compose up -d api\n\n# Verify mount\ndocker compose exec api ls /app/uploads/\n

Solution 4: Use absolute path

# Sometimes relative paths don't work\napi:\n  volumes:\n    - /home/user/changemaker.lite/uploads:/app/uploads\n

Solution 5: Check Docker Compose version

# Check version\ndocker compose version\n\n# Should be v2+\n# If v1, syntax might differ\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_11","title":"Prevention","text":""},{"location":"v2/troubleshooting/docker-issues/#data-persistence-problems","title":"Data Persistence Problems","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_12","title":"Symptoms","text":"

Data disappears after docker compose down:

"},{"location":"v2/troubleshooting/docker-issues/#common-causes_12","title":"Common Causes","text":"
  1. Using containers, not volumes - Data stored in container filesystem
  2. Anonymous volumes - Volume not named or bound
  3. Deleting volumes - docker compose down -v removes volumes
  4. Wrong volume type - tmpfs instead of volume
"},{"location":"v2/troubleshooting/docker-issues/#solutions_12","title":"Solutions","text":"

Solution 1: Use named volumes

In docker-compose.yml:

v2-postgres:\n  volumes:\n    - postgres-data:/var/lib/postgresql/data  # Named volume\n\nvolumes:\n  postgres-data:  # Declare named volume\n

Solution 2: Use bind mounts

v2-postgres:\n  volumes:\n    - ./data/postgres:/var/lib/postgresql/data  # Bind to host directory\n

Solution 3: Don't use -v flag

# Wrong - deletes volumes\ndocker compose down -v\n\n# Right - keeps volumes\ndocker compose down\n

Solution 4: Check volume exists

# List volumes\ndocker volume ls\n\n# Should show:\n# changemaker-lite_postgres-data\n\n# Inspect volume\ndocker volume inspect changemaker-lite_postgres-data\n

Solution 5: Backup before down

# Backup database before stopping\ndocker compose exec v2-postgres pg_dump -U changemaker changemaker_v2 > backup.sql\n\n# Then safe to:\ndocker compose down -v\n\n# Restore after up:\ndocker compose up -d v2-postgres\ndocker compose exec -T v2-postgres psql -U changemaker changemaker_v2 < backup.sql\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_12","title":"Prevention","text":""},{"location":"v2/troubleshooting/docker-issues/#performance-issues","title":"Performance Issues","text":""},{"location":"v2/troubleshooting/docker-issues/#slow-container-startup","title":"Slow Container Startup","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_13","title":"Symptoms","text":"

Container takes minutes to start:

docker compose up -d api\n# Creating api ... (2 minutes)\n# Creating api ... done\n
"},{"location":"v2/troubleshooting/docker-issues/#common-causes_13","title":"Common Causes","text":"
  1. Large image - Downloading/extracting large image
  2. Many dependencies - npm install taking long
  3. Health check delay - Waiting for health checks
  4. Slow disk - I/O bottleneck
"},{"location":"v2/troubleshooting/docker-issues/#solutions_13","title":"Solutions","text":"

Solution 1: Use pre-built image

# Instead of building locally\napi:\n  build: ./api\n\n# Use pre-built image from registry\napi:\n  image: ghcr.io/yourorg/changemaker-api:latest\n

Solution 2: Layer caching

# In Dockerfile, copy package files first\nCOPY package*.json ./\nRUN npm ci\n\n# Then copy code (changes more frequently)\nCOPY . .\nRUN npm run build\n

Solution 3: Multi-stage builds

# Build stage\nFROM node:20-alpine AS builder\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci\nCOPY . .\nRUN npm run build\n\n# Runtime stage (smaller)\nFROM node:20-alpine\nWORKDIR /app\nCOPY --from=builder /app/dist ./dist\nCOPY --from=builder /app/node_modules ./node_modules\nCOPY package*.json ./\nCMD [\"node\", \"dist/server.js\"]\n

Solution 4: Increase Docker resources

In Docker Desktop settings:

Solution 5: Parallel builds

# Build all services in parallel\ndocker compose build --parallel\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_13","title":"Prevention","text":""},{"location":"v2/troubleshooting/docker-issues/#high-cpu-usage","title":"High CPU Usage","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_14","title":"Symptoms","text":"
docker stats\n# CONTAINER   CPU %\n# api         95%\n

Container consuming excessive CPU.

"},{"location":"v2/troubleshooting/docker-issues/#common-causes_14","title":"Common Causes","text":"
  1. Infinite loop - Bug causing tight loop
  2. Heavy computation - Processing large dataset
  3. Too many workers - Worker threads maxed out
  4. Memory thrashing - Swapping due to low memory
"},{"location":"v2/troubleshooting/docker-issues/#solutions_14","title":"Solutions","text":"

Solution 1: Identify process

# Top inside container\ndocker compose exec api top\n\n# Shows process using CPU\n

Solution 2: Check for loops

# View logs for repeated messages\ndocker compose logs api | tail -100\n\n# Restart if stuck\ndocker compose restart api\n

Solution 3: Limit worker threads

// In BullMQ worker\nnew Worker('queueName', processor, {\n  concurrency: 2,  // Reduce from 10\n  limiter: {\n    max: 10,\n    duration: 1000  // Max 10 jobs per second\n  }\n});\n

Solution 4: Set CPU limits

api:\n  deploy:\n    resources:\n      limits:\n        cpus: '2.0'  # Max 2 CPUs\n

Solution 5: Profile application

# Use Node.js profiler\ndocker compose exec api node --prof dist/server.js\n\n# Or clinic.js\nnpm install -g clinic\nclinic doctor -- node dist/server.js\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_14","title":"Prevention","text":""},{"location":"v2/troubleshooting/docker-issues/#high-memory-usage","title":"High Memory Usage","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_15","title":"Symptoms","text":"
docker stats\n# CONTAINER   MEM USAGE / LIMIT\n# api         3.8GiB / 4GiB\n

Memory usage keeps increasing.

"},{"location":"v2/troubleshooting/docker-issues/#common-causes_15","title":"Common Causes","text":"
  1. Memory leak - Not releasing memory
  2. Large cache - Caching too much data
  3. Database connections - Too many open connections
  4. Large response bodies - Sending huge payloads
"},{"location":"v2/troubleshooting/docker-issues/#solutions_15","title":"Solutions","text":"

Solution 1: Identify memory usage

# Memory breakdown inside container\ndocker compose exec api sh -c 'cat /proc/meminfo'\n\n# Node.js heap stats\ndocker compose exec api node -e \"console.log(process.memoryUsage())\"\n

Solution 2: Restart to free memory

# Temporary fix\ndocker compose restart api\n\n# Memory should drop\ndocker stats api\n

Solution 3: Reduce cache size

// In Redis cache\nredis.set(key, value, 'EX', 3600);  // Expire after 1 hour\n\n// Limit cache size\nconst cache = new LRU({\n  max: 1000,  // Max 1000 entries\n  maxAge: 3600000  // 1 hour\n});\n

Solution 4: Set memory limit

api:\n  deploy:\n    resources:\n      limits:\n        memory: 2G  # Hard limit\n      reservations:\n        memory: 1G  # Reserved amount\n

Solution 5: Find memory leak

# Take heap snapshot\ndocker compose exec api node --expose-gc --inspect dist/server.js\n\n# Use Chrome DevTools to analyze\n# chrome://inspect\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_15","title":"Prevention","text":""},{"location":"v2/troubleshooting/docker-issues/#useful-commands","title":"Useful Commands","text":""},{"location":"v2/troubleshooting/docker-issues/#viewing-logs","title":"Viewing Logs","text":"
# Last 100 lines\ndocker compose logs api --tail=100\n\n# Follow logs (real-time)\ndocker compose logs -f api\n\n# All services\ndocker compose logs\n\n# Since timestamp\ndocker compose logs --since=\"2026-02-13T10:00:00\"\n\n# Filter by keyword\ndocker compose logs api | grep -i error\n\n# Save to file\ndocker compose logs api > api-logs.txt\n
"},{"location":"v2/troubleshooting/docker-issues/#executing-commands","title":"Executing Commands","text":"
# Run command in running container\ndocker compose exec api npm run migrate\n\n# Interactive shell\ndocker compose exec api sh\n\n# Run as different user\ndocker compose exec -u root api sh\n\n# Run in new container (one-off)\ndocker compose run --rm api npm test\n
"},{"location":"v2/troubleshooting/docker-issues/#inspecting-containers","title":"Inspecting Containers","text":"
# View container details\ndocker inspect changemaker-lite-api-1\n\n# View specific field\ndocker inspect changemaker-lite-api-1 --format='{{.State.Status}}'\n\n# View environment variables\ndocker inspect changemaker-lite-api-1 --format='{{range .Config.Env}}{{println .}}{{end}}'\n\n# View mounts\ndocker inspect changemaker-lite-api-1 --format='{{json .Mounts}}' | jq\n
"},{"location":"v2/troubleshooting/docker-issues/#container-management","title":"Container Management","text":"
# Start all services\ndocker compose up -d\n\n# Start specific service\ndocker compose up -d api\n\n# Stop all services\ndocker compose stop\n\n# Stop specific service\ndocker compose stop api\n\n# Restart service\ndocker compose restart api\n\n# Remove stopped containers\ndocker compose rm\n\n# Stop and remove\ndocker compose down\n
"},{"location":"v2/troubleshooting/docker-issues/#rebuilding","title":"Rebuilding","text":"
# Rebuild single service\ndocker compose build api\n\n# Rebuild without cache\ndocker compose build --no-cache api\n\n# Build all services\ndocker compose build\n\n# Build and start\ndocker compose up -d --build\n\n# Force recreate containers\ndocker compose up -d --force-recreate\n
"},{"location":"v2/troubleshooting/docker-issues/#log-analysis","title":"Log Analysis","text":""},{"location":"v2/troubleshooting/docker-issues/#reading-container-logs","title":"Reading Container Logs","text":"

Logs follow this pattern:

[timestamp] [level] [message]\n2026-02-13T10:30:00.000Z INFO Server started on port 4000\n
"},{"location":"v2/troubleshooting/docker-issues/#common-log-patterns","title":"Common Log Patterns","text":"

Successful startup:

INFO Connecting to database...\nINFO Database connected\nINFO Registered route: GET /api/health\nINFO Registered route: POST /api/auth/login\nINFO Server started on port 4000\n

Database connection error:

INFO Connecting to database...\nERROR Can't reach database server at `v2-postgres:5432`\nERROR Retrying in 5 seconds...\n

Missing environment variable:

ERROR Environment validation failed:\nERROR   SMTP_HOST is required\nERROR   JWT_ACCESS_SECRET is required\n

Health check failure:

WARN Health check failed: Database not connected\n
"},{"location":"v2/troubleshooting/docker-issues/#filtering-logs","title":"Filtering Logs","text":"
# Only errors\ndocker compose logs api | grep ERROR\n\n# Only warnings and errors\ndocker compose logs api | grep -E \"ERROR|WARN\"\n\n# Exclude health checks\ndocker compose logs api | grep -v \"GET /api/health\"\n\n# Find specific request\ndocker compose logs api | grep \"POST /api/users\"\n\n# Find by request ID\ndocker compose logs api | grep \"req-abc123\"\n
"},{"location":"v2/troubleshooting/docker-issues/#cleanup-commands","title":"Cleanup Commands","text":""},{"location":"v2/troubleshooting/docker-issues/#remove-stopped-containers","title":"Remove Stopped Containers","text":"
# Remove all stopped containers\ndocker compose down\n\n# Remove specific service containers\ndocker compose rm api\n\n# Force remove running containers\ndocker compose rm -f api\n
"},{"location":"v2/troubleshooting/docker-issues/#remove-images","title":"Remove Images","text":"
# Remove all images for project\ndocker compose down --rmi all\n\n# Remove only project-built images (not postgres, redis, etc.)\ndocker compose down --rmi local\n\n# Remove specific image\ndocker rmi changemaker-lite-api\n\n# Remove dangling images\ndocker image prune\n
"},{"location":"v2/troubleshooting/docker-issues/#remove-volumes","title":"Remove Volumes","text":"
# \u26a0\ufe0f WARNING: Deletes all data!\ndocker compose down -v\n\n# Remove specific volume\ndocker volume rm changemaker-lite_postgres-data\n\n# Remove unused volumes\ndocker volume prune\n
"},{"location":"v2/troubleshooting/docker-issues/#remove-networks","title":"Remove Networks","text":"
# Remove project network (containers must be stopped first)\ndocker network rm changemaker-lite\n\n# Remove unused networks\ndocker network prune\n
"},{"location":"v2/troubleshooting/docker-issues/#full-cleanup","title":"Full Cleanup","text":"
# \u26a0\ufe0f DANGER: Removes everything!\ndocker compose down -v --rmi all\ndocker system prune -a --volumes\n\n# This deletes:\n# - All containers\n# - All volumes (data lost!)\n# - All images\n# - All networks\n# - All build cache\n
"},{"location":"v2/troubleshooting/docker-issues/#safe-cleanup","title":"Safe Cleanup","text":"
# Safe cleanup (keeps volumes)\ndocker compose down\ndocker image prune -a\ndocker network prune\n\n# This keeps:\n# - Volumes (data safe)\n# - .env file\n# - Application code\n
"},{"location":"v2/troubleshooting/docker-issues/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/docker-issues/#docker-documentation","title":"Docker Documentation","text":""},{"location":"v2/troubleshooting/docker-issues/#other-troubleshooting","title":"Other Troubleshooting","text":""},{"location":"v2/troubleshooting/docker-issues/#docker-resources","title":"Docker Resources","text":"

Last Updated: February 2026 Version: V2.0 Status: Complete

"},{"location":"v2/troubleshooting/email-issues/","title":"Email and SMTP Issues","text":"

This guide covers email sending, SMTP configuration, and template-related problems in Changemaker Lite V2.

"},{"location":"v2/troubleshooting/email-issues/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/email-issues/#email-system-architecture","title":"Email System Architecture","text":"

Changemaker Lite V2 has dual email systems:

  1. Transactional Emails (BullMQ + Nodemailer)
  2. Campaign advocacy emails
  3. Shift confirmation emails
  4. Response verification emails
  5. System notifications

  6. Newsletter Emails (Listmonk)

  7. Marketing campaigns
  8. Newsletter broadcasts
  9. Subscriber management
"},{"location":"v2/troubleshooting/email-issues/#email-flow","title":"Email Flow","text":"
User Action \u2192 Email Service \u2192 BullMQ Queue \u2192 Worker \u2192 SMTP Server \u2192 Recipient\n
"},{"location":"v2/troubleshooting/email-issues/#key-components","title":"Key Components","text":""},{"location":"v2/troubleshooting/email-issues/#smtp-configuration","title":"SMTP Configuration","text":""},{"location":"v2/troubleshooting/email-issues/#connection-refused","title":"Connection Refused","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/email-issues/#symptoms","title":"Symptoms","text":"

API logs:

Error: Connection timeout\nError: connect ECONNREFUSED smtp.gmail.com:587\nError: Invalid login: 535-5.7.8 Username and Password not accepted\n

Emails not sending.

"},{"location":"v2/troubleshooting/email-issues/#common-causes","title":"Common Causes","text":"
  1. Wrong SMTP host - Incorrect hostname
  2. Port blocked - Firewall blocking port 587/465
  3. Wrong credentials - Invalid username/password
  4. TLS/SSL mismatch - Wrong secure setting
"},{"location":"v2/troubleshooting/email-issues/#solutions","title":"Solutions","text":"

Solution 1: Test SMTP connection

# Test with telnet\ntelnet smtp.gmail.com 587\n\n# Should show:\n# 220 smtp.gmail.com ESMTP ...\n\n# Or test with openssl (for SSL)\nopenssl s_client -connect smtp.gmail.com:465\n

Solution 2: Verify SMTP configuration

In .env:

# Gmail example (requires app password)\nSMTP_HOST=smtp.gmail.com\nSMTP_PORT=587\nSMTP_SECURE=false  # false for STARTTLS on 587, true for SSL on 465\nSMTP_USER=your-email@gmail.com\nSMTP_PASS=your-app-password  # NOT regular password\nSMTP_FROM=your-email@gmail.com\n\n# Office365 example\nSMTP_HOST=smtp.office365.com\nSMTP_PORT=587\nSMTP_SECURE=false\nSMTP_USER=your-email@outlook.com\nSMTP_PASS=your-password\nSMTP_FROM=your-email@outlook.com\n\n# SendGrid example\nSMTP_HOST=smtp.sendgrid.net\nSMTP_PORT=587\nSMTP_SECURE=false\nSMTP_USER=apikey  # Literally \"apikey\"\nSMTP_PASS=your-sendgrid-api-key\nSMTP_FROM=your-verified-sender@example.com\n

Solution 3: Use test mode

# In .env\nEMAIL_TEST_MODE=true\n\n# Restart API\ndocker compose restart api\n\n# All emails now sent to MailHog\n# View at http://localhost:8025\n

Solution 4: Test email sending

# Send test email via API\ncurl -X POST http://localhost:4000/api/test-email \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\n    \"to\": \"test@example.com\",\n    \"subject\": \"Test Email\",\n    \"text\": \"This is a test email from Changemaker Lite\"\n  }'\n\n# Check API logs\ndocker compose logs api | grep -i \"email\\|smtp\"\n

Solution 5: Gmail app password

For Gmail (required if 2FA enabled):

  1. Go to https://myaccount.google.com/apppasswords
  2. Select app: Mail
  3. Select device: Other (Changemaker Lite)
  4. Click Generate
  5. Copy 16-character password
  6. Use in SMTP_PASS (no spaces)
"},{"location":"v2/troubleshooting/email-issues/#prevention","title":"Prevention","text":""},{"location":"v2/troubleshooting/email-issues/#authentication-failed","title":"Authentication Failed","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/email-issues/#symptoms_1","title":"Symptoms","text":"
Error: Invalid login: 535-5.7.8 Username and Password not accepted\nError: 535 Authentication failed\n
"},{"location":"v2/troubleshooting/email-issues/#common-causes_1","title":"Common Causes","text":"
  1. Wrong password - Incorrect password
  2. 2FA enabled - Need app password
  3. Less secure apps - Gmail blocking
  4. Account locked - Too many failed attempts
"},{"location":"v2/troubleshooting/email-issues/#solutions_1","title":"Solutions","text":"

Solution 1: Verify credentials

# Check .env\ncat .env | grep SMTP_\n\n# Test login manually (if possible)\n# Gmail doesn't allow this, but some SMTP servers do\n

Solution 2: Enable less secure apps (Gmail)

\u26a0\ufe0f Not recommended. Use app password instead.

  1. Go to https://myaccount.google.com/lesssecureapps
  2. Turn on \"Allow less secure apps\"

Solution 3: Check account status

  1. Try logging into email account via web
  2. Check for security alerts
  3. Verify account not locked

Solution 4: Use OAuth2 (advanced)

For production Gmail:

// In email.service.ts\nconst transporter = nodemailer.createTransporter({\n  service: 'gmail',\n  auth: {\n    type: 'OAuth2',\n    user: process.env.SMTP_USER,\n    clientId: process.env.GMAIL_CLIENT_ID,\n    clientSecret: process.env.GMAIL_CLIENT_SECRET,\n    refreshToken: process.env.GMAIL_REFRESH_TOKEN\n  }\n});\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_1","title":"Prevention","text":""},{"location":"v2/troubleshooting/email-issues/#invalid-credentials","title":"Invalid Credentials","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/email-issues/#symptoms_2","title":"Symptoms","text":"
Error: Invalid SMTP credentials\nError: Username and Password not accepted\n
"},{"location":"v2/troubleshooting/email-issues/#solutions_2","title":"Solutions","text":"

See \"Authentication Failed\" section above.

"},{"location":"v2/troubleshooting/email-issues/#port-blocked","title":"Port Blocked","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/email-issues/#symptoms_3","title":"Symptoms","text":"
Error: connect ETIMEDOUT smtp.gmail.com:587\nError: Connection timeout\n

Connection attempt hangs, then times out after 30+ seconds.

"},{"location":"v2/troubleshooting/email-issues/#common-causes_2","title":"Common Causes","text":"
  1. Firewall blocking - Network firewall blocking port
  2. ISP blocking - ISP blocks port 25/587
  3. Docker network - Container can't reach external SMTP
"},{"location":"v2/troubleshooting/email-issues/#solutions_3","title":"Solutions","text":"

Solution 1: Test port access

# From API container\ndocker compose exec api telnet smtp.gmail.com 587\n\n# If timeout, port is blocked\n

Solution 2: Try alternative port

# Try port 465 (SSL) instead of 587 (STARTTLS)\nSMTP_PORT=465\nSMTP_SECURE=true\n\n# Or try port 2525 (some providers)\nSMTP_PORT=2525\nSMTP_SECURE=false\n

Solution 3: Check Docker network

# Test external connectivity\ndocker compose exec api ping -c 3 smtp.gmail.com\n\n# Test DNS resolution\ndocker compose exec api nslookup smtp.gmail.com\n\n# If fails, Docker network issue\n

Solution 4: Use SMTP relay

If ISP blocks SMTP, use relay service: - SendGrid - Mailgun - Amazon SES - Postmark

Solution 5: VPN or proxy

As last resort, route SMTP through VPN/proxy.

"},{"location":"v2/troubleshooting/email-issues/#prevention_2","title":"Prevention","text":""},{"location":"v2/troubleshooting/email-issues/#template-issues","title":"Template Issues","text":""},{"location":"v2/troubleshooting/email-issues/#template-not-found","title":"Template Not Found","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/email-issues/#symptoms_4","title":"Symptoms","text":"

API logs:

Error: Email template not found: campaign-email\nError: ENOENT: no such file or directory, open 'templates/campaign-email.html'\n

"},{"location":"v2/troubleshooting/email-issues/#common-causes_3","title":"Common Causes","text":"
  1. Template file missing - File doesn't exist
  2. Wrong template name - Typo in name
  3. Wrong directory - Looking in wrong path
  4. Deleted template - Template was removed
"},{"location":"v2/troubleshooting/email-issues/#solutions_4","title":"Solutions","text":"

Solution 1: List available templates

# List template files\ndocker compose exec api ls -la templates/\n\n# Should show:\n# campaign-email.html\n# shift-confirmation.html\n# verification-email.html\n# response-verification.html\n

Solution 2: Create missing template

# Create template file\ndocker compose exec api sh -c 'cat > templates/my-template.html << EOF\n<!DOCTYPE html>\n<html>\n<head>\n  <meta charset=\"UTF-8\">\n  <title>{{title}}</title>\n</head>\n<body>\n  <h1>Hello {{name}}</h1>\n  <p>{{message}}</p>\n</body>\n</html>\nEOF'\n

Solution 3: Use email template system

Navigate to /app/email-templates:

  1. Click \"Create Template\"
  2. Fill in details
  3. Design template
  4. Save (creates file + DB record)

Solution 4: Check template name

// In code, template name must match filename (without .html)\nawait emailService.sendEmail({\n  to: email,\n  subject: 'Campaign Email',\n  template: 'campaign-email',  // Looks for templates/campaign-email.html\n  variables: { ... }\n});\n

Solution 5: Verify template path

In api/src/services/email.service.ts:

const templatePath = path.join(__dirname, '../../templates', `${template}.html`);\n// Resolves to: api/templates/campaign-email.html\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_3","title":"Prevention","text":""},{"location":"v2/troubleshooting/email-issues/#variable-not-replaced","title":"Variable Not Replaced","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/email-issues/#symptoms_5","title":"Symptoms","text":"

Email received with unreplaced placeholders:

Hello {{name}},\n\nYour campaign {{campaignName}} is ready.\n
"},{"location":"v2/troubleshooting/email-issues/#common-causes_4","title":"Common Causes","text":"
  1. Variable not provided - Missing from variables object
  2. Typo in variable name - Mismatch between template and code
  3. Wrong delimiter - Using ${} instead of {{}}
  4. Escaping issue - HTML entities interfering
"},{"location":"v2/troubleshooting/email-issues/#solutions_5","title":"Solutions","text":"

Solution 1: List template variables

# Find all variables in template\ndocker compose exec api grep -o '{{[^}]*}}' templates/campaign-email.html\n\n# Shows:\n# {{name}}\n# {{campaignName}}\n# {{campaignUrl}}\n

Solution 2: Provide all variables

await emailService.sendEmail({\n  to: email,\n  subject: 'Campaign Ready',\n  template: 'campaign-email',\n  variables: {\n    name: user.name,  // Must provide ALL variables in template\n    campaignName: campaign.name,\n    campaignUrl: `${process.env.PUBLIC_URL}/campaigns/${campaign.id}`\n  }\n});\n

Solution 3: Check variable delimiter

<!-- Correct (Handlebars-style) -->\n<h1>Hello {{name}}</h1>\n<p>Your campaign {{campaignName}} is ready.</p>\n\n<!-- Wrong -->\n<h1>Hello ${name}</h1>  <!-- JavaScript template literal -->\n<p>Your campaign {campaignName} is ready.</p>  <!-- Single braces -->\n

Solution 4: Test template rendering

# Test template rendering\ncurl -X POST http://localhost:4000/api/test-template \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\n    \"template\": \"campaign-email\",\n    \"variables\": {\n      \"name\": \"John\",\n      \"campaignName\": \"Save the Planet\",\n      \"campaignUrl\": \"https://example.com/campaigns/123\"\n    }\n  }'\n\n# Returns rendered HTML\n

Solution 5: Use default values

<!-- In template, provide fallback -->\n<h1>Hello {{name || \"Friend\"}}</h1>\n

Or in code:

const variables = {\n  name: user.name || 'Friend',\n  campaignName: campaign.name || 'Campaign',\n  campaignUrl: campaignUrl || '#'\n};\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_4","title":"Prevention","text":""},{"location":"v2/troubleshooting/email-issues/#syntax-errors","title":"Syntax Errors","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/email-issues/#symptoms_6","title":"Symptoms","text":"
Error: Parse error in template at line 15\nError: Unexpected token in template\n

Email fails to send.

"},{"location":"v2/troubleshooting/email-issues/#common-causes_5","title":"Common Causes","text":"
  1. Invalid HTML - Malformed HTML
  2. Unclosed tags - Missing closing tags
  3. Special characters - Unescaped < > &
  4. Handlebars syntax - Invalid {{}} usage
"},{"location":"v2/troubleshooting/email-issues/#solutions_6","title":"Solutions","text":"

Solution 1: Validate HTML

# Use HTML validator\n# Copy template content to https://validator.w3.org/nu/\n\n# Or validate locally\ndocker compose exec api npx html-validate templates/campaign-email.html\n

Solution 2: Check common errors

<!-- Unclosed tag -->\n<div>Content here\n<!-- Should be: -->\n<div>Content here</div>\n\n<!-- Unescaped characters -->\nPrice: $50 < $100\n<!-- Should be: -->\nPrice: $50 &lt; $100\n\n<!-- Invalid Handlebars -->\n{{if name}}  <!-- No \"if\" helper by default -->\n<!-- Should be: -->\n{{#if name}}...{{/if}}  <!-- Or don't use if -->\n

Solution 3: Escape HTML

// In email.service.ts\nimport handlebars from 'handlebars';\n\n// Register escape helper\nhandlebars.registerHelper('escape', (str) => {\n  return handlebars.escapeExpression(str);\n});\n\n// In template\n<p>Message: {{escape message}}</p>\n

Solution 4: Test template compilation

// Test if template compiles\nimport handlebars from 'handlebars';\nimport fs from 'fs';\n\nconst templateSource = fs.readFileSync('templates/campaign-email.html', 'utf8');\ntry {\n  const template = handlebars.compile(templateSource);\n  console.log('Template compiles successfully');\n} catch (error) {\n  console.error('Template error:', error.message);\n}\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_5","title":"Prevention","text":""},{"location":"v2/troubleshooting/email-issues/#queue-issues","title":"Queue Issues","text":""},{"location":"v2/troubleshooting/email-issues/#queue-stuck","title":"Queue Stuck","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/email-issues/#symptoms_7","title":"Symptoms","text":"

Emails queued but not sending. Queue shows jobs but no progress.

"},{"location":"v2/troubleshooting/email-issues/#solutions_7","title":"Solutions","text":"

Solution 1: Check queue status

# View queue stats\ncurl http://localhost:4000/api/influence/email-queue/stats \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Shows:\n# {\n#   \"waiting\": 50,\n#   \"active\": 0,  # Should be > 0 if processing\n#   \"completed\": 1000,\n#   \"failed\": 5\n# }\n

Solution 2: Check worker is running

# Worker should log processing\ndocker compose logs api | grep -i \"email worker\\|processing email\"\n\n# Should show:\n# Email worker started\n# Processing email job for campaign: abc-123\n

Solution 3: Restart worker

# Restart API (restarts worker)\ndocker compose restart api\n\n# Check worker started\ndocker compose logs api | grep \"Email worker started\"\n

Solution 4: Check Redis

# Test Redis connection\ndocker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD ping\n\n# Check queue keys\ndocker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD keys \"bull:email-queue:*\"\n

Solution 5: Process stuck jobs

# Retry failed jobs\ncurl -X POST http://localhost:4000/api/influence/email-queue/retry-failed \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Clean old jobs\ncurl -X POST http://localhost:4000/api/influence/email-queue/clean \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\"status\": \"completed\", \"grace\": 86400000}'  # Clean completed > 1 day\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_6","title":"Prevention","text":""},{"location":"v2/troubleshooting/email-issues/#jobs-failing","title":"Jobs Failing","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/email-issues/#symptoms_8","title":"Symptoms","text":"

High failed job count. Emails not reaching recipients.

"},{"location":"v2/troubleshooting/email-issues/#solutions_8","title":"Solutions","text":"

Solution 1: View failed jobs

# Get failed job details\ncurl http://localhost:4000/api/influence/email-queue/failed \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Shows:\n# [\n#   {\n#     \"id\": \"123\",\n#     \"data\": { \"to\": \"user@example.com\", \"subject\": \"...\" },\n#     \"failedReason\": \"SMTP connection failed\",\n#     \"attemptsMade\": 3\n#   }\n# ]\n

Solution 2: Check error patterns

# Common failure reasons\ndocker compose logs api | grep \"Email failed\" | sort | uniq -c\n\n# Example output:\n#  25 Email failed: Invalid email address\n#  10 Email failed: SMTP connection refused\n#   3 Email failed: Recipient mailbox full\n

Solution 3: Retry with fixes

# Fix SMTP config if needed\n# Then retry failed jobs\ncurl -X POST http://localhost:4000/api/influence/email-queue/retry-failed \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n

Solution 4: Manual intervention

For repeatedly failing emails:

  1. Check email address validity
  2. Verify SMTP configuration
  3. Test with different recipient
  4. Check if recipient's mailbox full
"},{"location":"v2/troubleshooting/email-issues/#prevention_7","title":"Prevention","text":""},{"location":"v2/troubleshooting/email-issues/#delivery-issues","title":"Delivery Issues","text":""},{"location":"v2/troubleshooting/email-issues/#emails-not-arriving","title":"Emails Not Arriving","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/email-issues/#symptoms_9","title":"Symptoms","text":"

Emails sent successfully (no errors) but not received.

"},{"location":"v2/troubleshooting/email-issues/#common-causes_6","title":"Common Causes","text":"
  1. Spam folder - Filtered to spam
  2. Email delay - Taking long to deliver
  3. Email blocking - Recipient server blocking
  4. Wrong address - Typo in email address
"},{"location":"v2/troubleshooting/email-issues/#solutions_9","title":"Solutions","text":"

Solution 1: Check spam folder

  1. Check spam/junk folder
  2. Check promotions tab (Gmail)
  3. Mark as \"Not Spam\" to whitelist

Solution 2: Check email logs

# Verify email was sent\ndocker compose logs api | grep \"Email sent\"\n\n# Should show:\n# Email sent to user@example.com: Campaign Email\n

Solution 3: Use MailHog to test

# In .env\nEMAIL_TEST_MODE=true\n\n# Restart API\ndocker compose restart api\n\n# Send test email\ncurl -X POST http://localhost:4000/api/test-email \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\"to\": \"test@example.com\", \"subject\": \"Test\", \"text\": \"Test\"}'\n\n# Check MailHog\n# http://localhost:8025\n\n# If appears in MailHog, SMTP working\n# If not appearing in real inbox, delivery issue\n

Solution 4: Check email headers

In MailHog or received email: 1. View full headers 2. Check \"Received\" path 3. Look for spam scores 4. Check SPF/DKIM/DMARC status

Solution 5: Test with different address

# Try sending to different email provider\n# Gmail vs Outlook vs Yahoo\n# If some work and others don't, specific provider blocking\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_8","title":"Prevention","text":""},{"location":"v2/troubleshooting/email-issues/#marked-as-spam","title":"Marked as Spam","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/email-issues/#symptoms_10","title":"Symptoms","text":"

Emails consistently go to spam folder.

"},{"location":"v2/troubleshooting/email-issues/#solutions_10","title":"Solutions","text":"

Solution 1: Configure SPF

Add TXT record to DNS:

v=spf1 include:_spf.google.com ~all\n

Or for SendGrid:

v=spf1 include:sendgrid.net ~all\n

Solution 2: Configure DKIM

  1. Generate DKIM keys (via email provider)
  2. Add DKIM TXT record to DNS
  3. Enable DKIM signing in SMTP settings

Solution 3: Configure DMARC

Add TXT record to DNS:

v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com\n

Solution 4: Improve email content

Solution 5: Warm up IP

If using dedicated IP: 1. Start with low volume 2. Gradually increase over weeks 3. Monitor reputation scores

"},{"location":"v2/troubleshooting/email-issues/#prevention_9","title":"Prevention","text":""},{"location":"v2/troubleshooting/email-issues/#bounce-errors","title":"Bounce Errors","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/email-issues/#symptoms_11","title":"Symptoms","text":"
Email bounced: user@example.com\n554 Recipient address rejected: User unknown\n
"},{"location":"v2/troubleshooting/email-issues/#common-causes_7","title":"Common Causes","text":"
  1. Invalid address - Email doesn't exist
  2. Full mailbox - Recipient mailbox full
  3. Temporary failure - Server temporarily unavailable
  4. Blocked sender - Your domain/IP blocked
"},{"location":"v2/troubleshooting/email-issues/#solutions_11","title":"Solutions","text":"

Solution 1: Categorize bounces

Hard bounces (permanent): - User unknown - Domain doesn't exist - Invalid address format

Soft bounces (temporary): - Mailbox full - Server temporarily unavailable - Message too large

Solution 2: Handle hard bounces

# Remove hard bounce addresses\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET \\\"emailBounced\\\" = true\n      WHERE email = 'bounced@example.com';\"\n\n# Don't send to bounced addresses\n

Solution 3: Retry soft bounces

# Retry soft bounces after delay\ncurl -X POST http://localhost:4000/api/influence/email-queue/retry-failed \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n

Solution 4: Validate emails before sending

import validator from 'validator';\n\nconst isValidEmail = validator.isEmail(email);\nif (!isValidEmail) {\n  throw new Error('Invalid email address');\n}\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_10","title":"Prevention","text":""},{"location":"v2/troubleshooting/email-issues/#listmonk-integration","title":"Listmonk Integration","text":""},{"location":"v2/troubleshooting/email-issues/#api-connection-failed","title":"API Connection Failed","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/email-issues/#symptoms_12","title":"Symptoms","text":"
Error: Failed to connect to Listmonk API\nError: ECONNREFUSED localhost:9001\n
"},{"location":"v2/troubleshooting/email-issues/#solutions_12","title":"Solutions","text":"

Solution 1: Check Listmonk is running

docker compose ps listmonk\n\n# Should show \"Up\"\n# If not:\ndocker compose up -d listmonk\n

Solution 2: Verify API credentials

# Check .env\ncat .env | grep LISTMONK_\n\n# Required:\nLISTMONK_URL=http://listmonk:9001\nLISTMONK_ADMIN_USER=admin\nLISTMONK_ADMIN_PASSWORD=password\n

Solution 3: Test API connection

# From API container\ndocker compose exec api curl -u admin:password http://listmonk:9001/api/health\n\n# Should return:\n# {\"data\": \"OK\"}\n

Solution 4: Check Docker network

# Both on same network?\ndocker inspect changemaker-lite-api-1 | grep NetworkMode\ndocker inspect changemaker-lite-listmonk-1 | grep NetworkMode\n\n# Should both show \"changemaker-lite\"\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_11","title":"Prevention","text":""},{"location":"v2/troubleshooting/email-issues/#sync-errors","title":"Sync Errors","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/email-issues/#symptoms_13","title":"Symptoms","text":"
Error: Failed to sync subscribers to Listmonk\nError: 400 Bad Request: Invalid email format\n
"},{"location":"v2/troubleshooting/email-issues/#solutions_13","title":"Solutions","text":"

Solution 1: Check sync status

Navigate to /app/listmonk:

Solution 2: View sync logs

docker compose logs api | grep -i \"listmonk\\|sync\"\n\n# Shows:\n# Syncing 150 participants to Listmonk\n# Created list: Campaign Participants\n# Added 145 subscribers, 5 failed\n

Solution 3: Manual sync

# Trigger manual sync\ncurl -X POST http://localhost:4000/api/listmonk/sync \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n

Solution 4: Check subscriber data

# View failed subscribers\ndocker compose logs api | grep \"Failed to add subscriber\"\n\n# Common issues:\n# - Invalid email format\n# - Email already exists\n# - Missing required fields\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_12","title":"Prevention","text":""},{"location":"v2/troubleshooting/email-issues/#performance-issues","title":"Performance Issues","text":""},{"location":"v2/troubleshooting/email-issues/#slow-email-sending","title":"Slow Email Sending","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/email-issues/#symptoms_14","title":"Symptoms","text":"

Sending emails takes several seconds each. Bulk sends very slow.

"},{"location":"v2/troubleshooting/email-issues/#solutions_14","title":"Solutions","text":"

Solution 1: Use queue system

# Don't send synchronously\n# Queue emails instead\ncurl -X POST http://localhost:4000/api/influence/campaigns/CAMPAIGN_ID/send-bulk \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Processes in background via queue\n

Solution 2: Increase worker concurrency

In api/src/services/email-queue.service.ts:

const worker = new Worker('email-queue', processor, {\n  concurrency: 5,  // Process 5 emails at a time (default: 1)\n  limiter: {\n    max: 50,  // Max 50 emails per second\n    duration: 1000\n  }\n});\n

Solution 3: Use batch sending

For transactional email services:

// Some SMTP services support batch sending\n// Send 100 emails in single API call instead of 100 separate calls\n

Solution 4: Check SMTP performance

# Test SMTP connection speed\ntime curl -X POST http://localhost:4000/api/test-email \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\"to\": \"test@example.com\", \"subject\": \"Test\", \"text\": \"Test\"}'\n\n# Should complete in < 2 seconds\n# If > 5 seconds, SMTP server slow\n

Solution 5: Use email service

For high volume, use transactional email service: - SendGrid - Mailgun - Amazon SES - Postmark

Faster and more reliable than SMTP.

"},{"location":"v2/troubleshooting/email-issues/#prevention_13","title":"Prevention","text":""},{"location":"v2/troubleshooting/email-issues/#queue-backlog","title":"Queue Backlog","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/email-issues/#symptoms_15","title":"Symptoms","text":"

Thousands of emails waiting in queue. Taking hours to process.

"},{"location":"v2/troubleshooting/email-issues/#solutions_15","title":"Solutions","text":"

Solution 1: Increase worker count

Start multiple API instances:

# In docker-compose.yml\napi:\n  deploy:\n    replicas: 3  # 3 API instances\n

Each instance runs its own worker.

Solution 2: Increase concurrency

See \"Slow Email Sending\" section above.

Solution 3: Pause new emails

# Pause queue\ncurl -X POST http://localhost:4000/api/influence/email-queue/pause \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Process backlog\n# Resume when caught up\ncurl -X POST http://localhost:4000/api/influence/email-queue/resume \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n

Solution 4: Clean old jobs

# Remove completed jobs\ncurl -X POST http://localhost:4000/api/influence/email-queue/clean \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\"status\": \"completed\", \"grace\": 3600000}'  # Older than 1 hour\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_14","title":"Prevention","text":""},{"location":"v2/troubleshooting/email-issues/#useful-commands","title":"Useful Commands","text":""},{"location":"v2/troubleshooting/email-issues/#testing-email","title":"Testing Email","text":"
# Send test email\ncurl -X POST http://localhost:4000/api/test-email \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\n    \"to\": \"test@example.com\",\n    \"subject\": \"Test Email\",\n    \"text\": \"This is a test email\",\n    \"html\": \"<h1>Test Email</h1><p>This is a test email</p>\"\n  }'\n\n# Test with template\ncurl -X POST http://localhost:4000/api/test-template-email \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\n    \"to\": \"test@example.com\",\n    \"subject\": \"Test Template\",\n    \"template\": \"campaign-email\",\n    \"variables\": {\n      \"name\": \"Test User\",\n      \"campaignName\": \"Test Campaign\"\n    }\n  }'\n
"},{"location":"v2/troubleshooting/email-issues/#queue-management","title":"Queue Management","text":"
# Get queue stats\ncurl http://localhost:4000/api/influence/email-queue/stats \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Pause queue\ncurl -X POST http://localhost:4000/api/influence/email-queue/pause \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Resume queue\ncurl -X POST http://localhost:4000/api/influence/email-queue/resume \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Retry failed\ncurl -X POST http://localhost:4000/api/influence/email-queue/retry-failed \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Clean completed\ncurl -X POST http://localhost:4000/api/influence/email-queue/clean \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\"status\": \"completed\", \"grace\": 86400000}'\n
"},{"location":"v2/troubleshooting/email-issues/#listmonk-operations","title":"Listmonk Operations","text":"
# Test Listmonk connection\ncurl -u admin:password http://localhost:9001/api/health\n\n# Get lists\ncurl -u admin:password http://localhost:9001/api/lists\n\n# Sync subscribers\ncurl -X POST http://localhost:4000/api/listmonk/sync \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Get sync status\ncurl http://localhost:4000/api/listmonk/status \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n
"},{"location":"v2/troubleshooting/email-issues/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/email-issues/#email-documentation","title":"Email Documentation","text":""},{"location":"v2/troubleshooting/email-issues/#other-troubleshooting","title":"Other Troubleshooting","text":""},{"location":"v2/troubleshooting/email-issues/#external-resources","title":"External Resources","text":"

Last Updated: February 2026 Version: V2.0 Status: Complete

"},{"location":"v2/troubleshooting/faq/","title":"Frequently Asked Questions (FAQ)","text":"

Comprehensive answers to common questions about Changemaker Lite V2.

"},{"location":"v2/troubleshooting/faq/#general-questions","title":"General Questions","text":""},{"location":"v2/troubleshooting/faq/#what-is-changemaker-lite","title":"What is Changemaker Lite?","text":"

Changemaker Lite is a self-hosted political campaign platform that consolidates:

Key features:

"},{"location":"v2/troubleshooting/faq/#v1-vs-v2-differences","title":"V1 vs V2 Differences","text":"Aspect V1 V2 Architecture Two separate Node apps (Influence + Map) Single unified Express API Database NocoDB REST API PostgreSQL 16 + Prisma ORM Authentication Sessions (express-session) JWT (access + refresh tokens) Frontend EJS templates React + Vite + Ant Design State Server-side Zustand (client-side) Email Bull queues BullMQ queues Monitoring Basic logging Prometheus + Grafana + Alertmanager Security Basic Production-grade (audit completed) Status Legacy (reference only) Current (active development)

Migration path: V1 \u2192 V2 requires data export/import. See Migration Guide.

"},{"location":"v2/troubleshooting/faq/#system-requirements","title":"System Requirements","text":"

Minimum (Development):

Recommended (Production):

External services (optional):

"},{"location":"v2/troubleshooting/faq/#browser-compatibility","title":"Browser Compatibility","text":"

Supported browsers:

Mobile browsers:

Required features:

"},{"location":"v2/troubleshooting/faq/#installation-setup","title":"Installation & Setup","text":""},{"location":"v2/troubleshooting/faq/#how-to-install","title":"How to Install?","text":"

Quick start:

# 1. Clone repository\ngit clone <repo-url> changemaker.lite\ncd changemaker.lite\ngit checkout v2\n\n# 2. Create environment file\ncp .env.example .env\nnano .env  # Edit and set passwords/secrets\n\n# 3. Start services\ndocker compose up -d v2-postgres redis api admin\n\n# 4. Run migrations\ndocker compose exec api npx prisma migrate deploy\ndocker compose exec api npx prisma db seed\n\n# 5. Access application\n# Admin GUI: http://localhost:3000\n# API: http://localhost:4000\n# Login: admin@example.com / Admin123!\n\n# 6. Change default password immediately\n

See Installation Guide for detailed instructions.

"},{"location":"v2/troubleshooting/faq/#default-credentials","title":"Default Credentials","text":"

Admin user (created by seed):

\u26a0\ufe0f IMPORTANT: Change this password immediately after first login!

Other services:

"},{"location":"v2/troubleshooting/faq/#how-to-change-password","title":"How to Change Password?","text":"

Via Admin UI (recommended):

  1. Login to admin at http://localhost:3000
  2. Navigate to Users (/app/users)
  3. Click user row
  4. Click Edit
  5. Enter new password (12+ chars, uppercase, lowercase, digit)
  6. Save

Via database:

# Generate bcrypt hash\ndocker compose exec api node -e \"\nconst bcrypt = require('bcryptjs');\nconsole.log(bcrypt.hashSync('NewPassword123!', 10));\n\"\n\n# Update password\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET password = 'PASTE_HASH_HERE' WHERE email = 'admin@example.com';\"\n

Password requirements:

"},{"location":"v2/troubleshooting/faq/#how-to-enable-https","title":"How to Enable HTTPS?","text":"

Changemaker Lite doesn't include HTTPS natively. Use one of these options:

Option 1: Pangolin Tunnel (Recommended)

Built-in integration:

  1. Navigate to /app/pangolin
  2. Follow setup wizard
  3. Configure tunnel
  4. Access via HTTPS URL provided by Pangolin

See Pangolin Integration.

Option 2: Cloudflare Tunnel

  1. Install cloudflared
  2. Configure tunnel
  3. Point to localhost:3000 (admin) and localhost:4000 (API)

Option 3: Reverse Proxy

Add nginx/Caddy in front:

# docker-compose.yml\nreverse-proxy:\n  image: nginx:alpine\n  ports:\n    - \"443:443\"\n  volumes:\n    - ./nginx/ssl.conf:/etc/nginx/nginx.conf\n    - ./ssl:/etc/nginx/ssl  # Your SSL certificates\n

Option 4: Hosting Provider

Deploy to provider with built-in HTTPS: - DigitalOcean App Platform - Heroku - Railway - Render

"},{"location":"v2/troubleshooting/faq/#user-management","title":"User Management","text":""},{"location":"v2/troubleshooting/faq/#how-to-create-users","title":"How to Create Users?","text":"

Via Admin UI (recommended):

  1. Navigate to /app/users
  2. Click \"Create User\"
  3. Fill in form:
  4. Email (required, unique)
  5. Password (required, 12+ chars)
  6. Name (required)
  7. Role (default: USER)
  8. Click \"Create\"

Via API:

curl -X POST http://localhost:4000/api/auth/register \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"email\": \"newuser@example.com\",\n    \"password\": \"SecurePass123!\",\n    \"name\": \"New User\"\n  }'\n

Via database:

# Generate password hash first\ndocker compose exec api node -e \"\nconst bcrypt = require('bcryptjs');\nconsole.log(bcrypt.hashSync('Password123!', 10));\n\"\n\n# Create user\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"INSERT INTO \\\"User\\\" (id, email, password, name, role)\n      VALUES (gen_random_uuid(), 'user@example.com', 'HASH_HERE', 'User Name', 'USER');\"\n
"},{"location":"v2/troubleshooting/faq/#how-to-reset-passwords","title":"How to Reset Passwords?","text":"

Current: V2 doesn't have password reset flow yet (planned for Phase 15).

Workaround: Reset manually via database (see \"How to Change Password?\" above).

Future: Will include: - Forgot password form - Email with reset link - 24-hour expiration - One-time use tokens

"},{"location":"v2/troubleshooting/faq/#what-are-the-user-roles","title":"What are the User Roles?","text":"Role Level Capabilities SUPER_ADMIN 5 Full access to everything (users, settings, all features) INFLUENCE_ADMIN 4 Manage campaigns, responses, email queue MAP_ADMIN 3 Manage locations, cuts, shifts, canvassing USER 2 View public content, participate in canvassing (if assigned) TEMP 1 Very limited - shift signup confirmation only

Permission matrix:

Feature SUPER_ADMIN INFLUENCE_ADMIN MAP_ADMIN USER TEMP User management \u2705 \u274c \u274c \u274c \u274c Site settings \u2705 \u274c \u274c \u274c \u274c Campaigns (admin) \u2705 \u2705 \u274c \u274c \u274c Responses (moderation) \u2705 \u2705 \u274c \u274c \u274c Email queue \u2705 \u2705 \u274c \u274c \u274c Locations (admin) \u2705 \u274c \u2705 \u274c \u274c Cuts (admin) \u2705 \u274c \u2705 \u274c \u274c Shifts (admin) \u2705 \u274c \u2705 \u274c \u274c Canvass dashboard \u2705 \u274c \u2705 \u274c \u274c View public campaigns \u2705 \u2705 \u2705 \u2705 \u274c View public map \u2705 \u2705 \u2705 \u2705 \u274c Sign up for shifts \u2705 \u2705 \u2705 \u2705 \u2705 Canvass (volunteer) \u2705 \u2705 \u2705 \u2705 \u274c

Login redirects:

"},{"location":"v2/troubleshooting/faq/#how-to-suspend-users","title":"How to Suspend Users?","text":"

Current: V2 doesn't have user suspension yet (planned for Phase 15).

Workaround: Delete user account or change role to TEMP (limited permissions).

Future: Will include: - Suspended flag on User model - Suspension reason tracking - Auto-logout suspended users - Reactivation workflow

"},{"location":"v2/troubleshooting/faq/#campaigns","title":"Campaigns","text":""},{"location":"v2/troubleshooting/faq/#how-to-create-campaign","title":"How to Create Campaign?","text":"
  1. Navigate to /app/influence/campaigns
  2. Click \"Create Campaign\"
  3. Fill in form:
  4. Name (required) - Campaign title
  5. Slug (required, unique) - URL-friendly name
  6. Description (optional) - Campaign details
  7. Email Subject (optional) - Default email subject
  8. Email Body (optional) - Default email template
  9. Active (checkbox) - Show on public site
  10. Allow Custom Message (checkbox) - Let users edit message
  11. Click \"Create\"
  12. Campaign now appears in admin table and public listing (if active)
"},{"location":"v2/troubleshooting/faq/#how-to-publish-campaign","title":"How to Publish Campaign?","text":"
  1. Navigate to /app/influence/campaigns
  2. Find campaign in table
  3. Click row to expand
  4. Toggle \"Active\" switch to ON
  5. Campaign now visible at /campaigns (public)
"},{"location":"v2/troubleshooting/faq/#how-to-track-emails","title":"How to Track Emails?","text":"
  1. Navigate to /app/influence/campaigns
  2. Click campaign row
  3. Click \"View Emails\" button
  4. Drawer shows:
  5. Total emails sent
  6. Email list with timestamps
  7. Recipient addresses
  8. Email status (sent/failed)

Via Email Queue Page:

  1. Navigate to /app/influence/email-queue
  2. View stats:
  3. Total emails processed
  4. Success/fail counts
  5. Queue depth
  6. View recent jobs
  7. Retry failed jobs if needed
"},{"location":"v2/troubleshooting/faq/#how-to-moderate-responses","title":"How to Moderate Responses?","text":"
  1. Navigate to /app/influence/responses
  2. Table shows all responses with:
  3. Participant name/email
  4. Campaign
  5. Message excerpt
  6. Submission timestamp
  7. Verification status
  8. Filters:
  9. Campaign dropdown
  10. Verified/unverified toggle
  11. Click row to view full response
  12. Actions:
  13. Verify (if unverified)
  14. Delete (if inappropriate)

Response verification workflow:

  1. User submits response \u2192 marked unverified
  2. User receives verification email
  3. User clicks verification link \u2192 marked verified
  4. Only verified responses show on public response wall
"},{"location":"v2/troubleshooting/faq/#map-canvassing","title":"Map & Canvassing","text":""},{"location":"v2/troubleshooting/faq/#how-to-import-locations","title":"How to Import Locations?","text":"

Via CSV:

  1. Navigate to /app/map/locations
  2. Click \"Import CSV\"
  3. Prepare CSV with columns:
    address,city,province,postalCode,notes\n123 Main St,Toronto,ON,M5H 2N2,Corner house\n456 Oak Ave,Toronto,ON,M5H 2N3,Blue door\n
  4. Upload file
  5. Map columns (if headers don't match exactly)
  6. Click \"Import\"
  7. Locations imported, geocoding starts automatically

Via NAR (Canadian Electoral Data):

  1. Obtain NAR data files (Location + Address)
  2. Place in /data directory (mapped volume)
  3. Navigate to /app/map/locations
  4. Click \"NAR Import\" tab
  5. Select province
  6. Select dataset
  7. Apply filters (city, postal code, cut, residential only)
  8. Preview count
  9. Click \"Import\"
  10. Import processes in background (can take minutes for large files)

See NAR Import Guide.

"},{"location":"v2/troubleshooting/faq/#how-to-create-cuts","title":"How to Create Cuts?","text":"

Via Map Drawing:

  1. Navigate to /app/map/cuts
  2. Click \"Map Drawing\" tab
  3. Map shows with drawing controls
  4. Click \"Draw Cut\" button
  5. Click on map to place vertices
  6. Click first vertex again to close polygon (or click \"Finish\")
  7. Fill in form:
  8. Name (required)
  9. Description (optional)
  10. Color (pick color for map display)
  11. Click \"Save\"

Via GeoJSON Import:

  1. Prepare GeoJSON file:
    {\n  \"type\": \"Polygon\",\n  \"coordinates\": [[\n    [-79.38, 43.65],\n    [-79.37, 43.65],\n    [-79.37, 43.64],\n    [-79.38, 43.64],\n    [-79.38, 43.65]\n  ]]\n}\n
  2. Navigate to /app/map/cuts
  3. Click \"Create Cut\"
  4. Paste GeoJSON in geometry field
  5. Fill in name/description
  6. Click \"Create\"
"},{"location":"v2/troubleshooting/faq/#how-to-organize-shifts","title":"How to Organize Shifts?","text":"
  1. Navigate to /app/map/shifts
  2. Click \"Create Shift\"
  3. Fill in form:
  4. Title (required) - Shift name
  5. Description (optional) - Shift details
  6. Start Time (required) - When shift starts
  7. End Time (required) - When shift ends
  8. Cut (optional) - Assign to specific cut
  9. Max Volunteers (optional) - Capacity limit
  10. Public (checkbox) - Show on public shifts page
  11. Click \"Create\"
  12. Shift appears in admin table and public listing (if public)

Manage signups:

  1. Click shift row in table
  2. Click \"View Signups\"
  3. Drawer shows:
  4. Signup count
  5. List of volunteers
  6. Email addresses
  7. Actions:
  8. \"Email All\" - Send message to all volunteers
  9. Remove individual signups if needed
"},{"location":"v2/troubleshooting/faq/#how-to-start-canvassing","title":"How to Start Canvassing?","text":"

For volunteers:

  1. Login to volunteer portal
  2. Navigate to \"My Assignments\" (/volunteer/assignments)
  3. Find assigned shift
  4. Click \"Start Canvassing\"
  5. Full-screen map opens (/volunteer/canvass/:cutId)
  6. GPS tracks your location
  7. Map shows:
  8. Your current position (blue dot)
  9. Locations in cut (markers)
  10. Walking route (blue line)
  11. Legend (outcome colors)
  12. Click location marker to record visit:
  13. Select outcome (Home, Away, Refused, etc.)
  14. Add notes (optional)
  15. Save
  16. Continue until all locations visited
  17. Session auto-saves progress

For admins (monitoring):

  1. Navigate to /app/canvass/dashboard
  2. View:
  3. Active sessions count
  4. Total visits recorded
  5. Recent activity feed
  6. Cut progress (% complete)
  7. Leaderboard (top canvassers)
  8. Click activity item to see details

See Canvassing Guide.

"},{"location":"v2/troubleshooting/faq/#technical-questions","title":"Technical Questions","text":""},{"location":"v2/troubleshooting/faq/#which-database","title":"Which Database?","text":"

PostgreSQL 16 with two ORMs:

  1. Prisma - Main API (Express)
  2. Schema: api/prisma/schema.prisma
  3. Migrations: api/prisma/migrations/
  4. 30+ models (User, Campaign, Location, etc.)

  5. Drizzle - Media API (Fastify)

  6. Schema: api/src/modules/media/db/schema.ts
  7. Tables: media_videos, media_reactions, etc.

Connection:

Shared database: Both ORMs use same PostgreSQL database, different tables.

"},{"location":"v2/troubleshooting/faq/#which-orm","title":"Which ORM?","text":"

Prisma for main API:

Drizzle for media API:

Why two ORMs?

Media API was added later as separate Fastify microservice. Using Drizzle allowed faster development without modifying main Prisma schema.

"},{"location":"v2/troubleshooting/faq/#api-architecture","title":"API Architecture?","text":"

Dual API architecture:

  1. Express API (Main)
  2. Port: 4000
  3. Language: TypeScript
  4. ORM: Prisma
  5. Features: Auth, campaigns, locations, shifts, canvass, pages
  6. Endpoints: /api/*

  7. Fastify Media API (Microservice)

  8. Port: 4100
  9. Language: TypeScript
  10. ORM: Drizzle
  11. Features: Video library, uploads, reactions
  12. Endpoints: /api/media/*

Shared:

Frontend:

"},{"location":"v2/troubleshooting/faq/#authentication-method","title":"Authentication Method?","text":"

JWT-based authentication:

Tokens:

  1. Access Token
  2. Duration: 15 minutes
  3. Stored: Memory (localStorage)
  4. Contains: userId, email, role
  5. Used: All authenticated requests

  6. Refresh Token

  7. Duration: 7 days
  8. Stored: Database + localStorage
  9. Used: Renew access token
  10. Rotation: New refresh token on each refresh

Flow:

  1. Login \u2192 Returns access + refresh tokens
  2. Store in localStorage (Zustand persist)
  3. Add access token to Authorization header
  4. Access token expires after 15min
  5. Frontend auto-refreshes using refresh token
  6. New access + refresh tokens returned
  7. Continue seamlessly

Security features:

See Authentication Flow.

"},{"location":"v2/troubleshooting/faq/#performance","title":"Performance","text":""},{"location":"v2/troubleshooting/faq/#how-many-users-supported","title":"How Many Users Supported?","text":"

Concurrent users:

Factors:

Scaling:

"},{"location":"v2/troubleshooting/faq/#how-to-scale","title":"How to Scale?","text":"

Horizontal scaling (recommended):

# docker-compose.yml\napi:\n  deploy:\n    replicas: 3  # Run 3 API instances\n  # Each instance:\n  # - Handles requests independently\n  # - Connects to same database\n  # - Processes queue jobs\n  # - Shares Redis cache\n

Add load balancer in front:

nginx:\n  image: nginx:alpine\n  ports:\n    - \"80:80\"\n  volumes:\n    - ./nginx/lb.conf:/etc/nginx/nginx.conf\n  # Distributes requests across API instances\n

Vertical scaling:

Increase resources:

api:\n  deploy:\n    resources:\n      limits:\n        cpus: '4.0'  # More CPU\n        memory: 8G   # More RAM\n

Database scaling:

Caching:

"},{"location":"v2/troubleshooting/faq/#database-size-limits","title":"Database Size Limits?","text":"

PostgreSQL:

Typical sizes (after 1 year):

Storage requirements:

Optimization:

"},{"location":"v2/troubleshooting/faq/#security","title":"Security","text":""},{"location":"v2/troubleshooting/faq/#is-data-encrypted","title":"Is Data Encrypted?","text":"

At rest:

In transit:

Recommendations:

"},{"location":"v2/troubleshooting/faq/#password-requirements","title":"Password Requirements?","text":"

Enforced policy:

Valid examples:

Invalid:

Storage:

"},{"location":"v2/troubleshooting/faq/#how-to-backup","title":"How to Backup?","text":"

Manual backup:

# Use provided script\n./scripts/backup.sh\n\n# Creates:\n# - PostgreSQL dump\n# - Listmonk dump (if enabled)\n# - Uploads archive (videos, images)\n# - Timestamped filename: backup_2026-02-13_100000.tar.gz\n

What's included:

What's NOT included:

Automated backups:

Add cron job:

# Run daily at 2 AM\n0 2 * * * cd /path/to/changemaker.lite && ./scripts/backup.sh\n\n# With S3 upload (if configured)\n0 2 * * * cd /path/to/changemaker.lite && ./scripts/backup.sh --upload-s3\n

Restore:

# Stop services\ndocker compose down\n\n# Restore database\ngunzip -c backup.sql.gz | docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2\n\n# Restore uploads\ntar -xzf uploads.tar.gz -C ./uploads\n\n# Start services\ndocker compose up -d\n

See Backup Guide.

"},{"location":"v2/troubleshooting/faq/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/troubleshooting/faq/#where-are-logs","title":"Where are Logs?","text":"

Docker logs:

# View API logs\ndocker compose logs api\n\n# View all logs\ndocker compose logs\n\n# Follow logs (real-time)\ndocker compose logs -f api\n\n# Last 100 lines\ndocker compose logs api --tail=100\n\n# Since timestamp\ndocker compose logs api --since=\"2026-02-13T10:00:00\"\n\n# Save to file\ndocker compose logs api > api-logs.txt\n

Log locations inside containers:

Log levels:

"},{"location":"v2/troubleshooting/faq/#how-to-restart-services","title":"How to Restart Services?","text":"

Restart specific service:

# Restart API\ndocker compose restart api\n\n# Restart multiple services\ndocker compose restart api admin v2-postgres\n

Restart all services:

# Graceful restart (preserves data)\ndocker compose restart\n\n# Stop and start (recreates containers)\ndocker compose down\ndocker compose up -d\n

Force recreate:

# Rebuild and recreate\ndocker compose up -d --build --force-recreate\n\n# Recreate specific service\ndocker compose up -d --build --force-recreate api\n

Restart single container:

# Get container name\ndocker compose ps\n\n# Restart by name\ndocker restart changemaker-lite-api-1\n
"},{"location":"v2/troubleshooting/faq/#how-to-reset-database","title":"How to Reset Database?","text":"

\u26a0\ufe0f WARNING: This deletes ALL data!

Full reset:

# Stop services\ndocker compose down\n\n# Delete database volume\ndocker volume rm changemaker-lite_postgres-data\n\n# Start fresh\ndocker compose up -d v2-postgres\n\n# Wait for database ready\nsleep 10\n\n# Run migrations\ndocker compose exec api npx prisma migrate deploy\n\n# Seed initial data\ndocker compose exec api npx prisma db seed\n\n# Default admin: admin@example.com / Admin123!\n

Reset specific tables:

# Delete all users (keeps schema)\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c 'TRUNCATE \"User\" CASCADE;'\n\n# Re-seed\ndocker compose exec api npx prisma db seed\n

Reset without deleting volumes:

# Drop and recreate database\ndocker compose exec v2-postgres psql -U postgres \\\n  -c 'DROP DATABASE changemaker_v2;'\n\ndocker compose exec v2-postgres psql -U postgres \\\n  -c 'CREATE DATABASE changemaker_v2 OWNER changemaker;'\n\n# Run migrations\ndocker compose exec api npx prisma migrate deploy\ndocker compose exec api npx prisma db seed\n
"},{"location":"v2/troubleshooting/faq/#getting-help","title":"Getting Help","text":""},{"location":"v2/troubleshooting/faq/#documentation-links","title":"Documentation Links","text":"

User Guides:

Technical Documentation:

Troubleshooting:

"},{"location":"v2/troubleshooting/faq/#github-issues","title":"GitHub Issues","text":"

Before creating issue:

  1. Check existing issues
  2. Search closed issues (may already be fixed)
  3. Check Troubleshooting guides
  4. Try latest version (git pull origin v2)

Creating good issues:

Bug reports:

**Describe the bug**\nClear description of what's wrong.\n\n**To Reproduce**\n1. Go to '...'\n2. Click on '...'\n3. See error\n\n**Expected behavior**\nWhat should happen instead.\n\n**Screenshots**\nIf applicable, add screenshots.\n\n**Environment**\n- OS: [e.g. Ubuntu 22.04]\n- Docker version: [e.g. 20.10.21]\n- Browser: [e.g. Chrome 120]\n\n**Logs**\nPaste relevant logs (sanitize sensitive data).\n

Feature requests:

**Is your feature request related to a problem?**\nDescription of problem.\n\n**Describe the solution you'd like**\nClear description of feature.\n\n**Describe alternatives you've considered**\nOther solutions considered.\n\n**Additional context**\nAny other context or screenshots.\n
"},{"location":"v2/troubleshooting/faq/#community-support","title":"Community Support","text":"

Official channels:

Response time:

Contributing:

Last Updated: February 2026 Version: V2.0 Status: Complete

"},{"location":"v2/troubleshooting/geocoding-issues/","title":"Geocoding and Map Issues","text":"

This guide covers geocoding, map display, and location-related problems in Changemaker Lite V2.

"},{"location":"v2/troubleshooting/geocoding-issues/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/geocoding-issues/#geocoding-system","title":"Geocoding System","text":"

Changemaker Lite V2 uses multi-provider geocoding with automatic fallback:

  1. Google Geocoding API - Most accurate, requires API key
  2. Mapbox Geocoding API - Good quality, requires API key
  3. Nominatim (OpenStreetMap) - Free, no key required
  4. ArcGIS Geocoding Service - Good for North America
  5. Photon (OpenStreetMap) - Free alternative
  6. HERE Geocoding API - Paid option
"},{"location":"v2/troubleshooting/geocoding-issues/#geocoding-queue","title":"Geocoding Queue","text":""},{"location":"v2/troubleshooting/geocoding-issues/#map-display","title":"Map Display","text":""},{"location":"v2/troubleshooting/geocoding-issues/#geocoding-failures","title":"Geocoding Failures","text":""},{"location":"v2/troubleshooting/geocoding-issues/#address-not-found","title":"Address Not Found","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms","title":"Symptoms","text":"

Location shows null latitude/longitude after geocoding attempt.

API logs:

WARN Geocoding failed for address: \"123 Fake St, Nowhere\": No results from any provider\n

"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes","title":"Common Causes","text":"
  1. Invalid address - Address doesn't exist
  2. Typo - Misspelled street/city/postal code
  3. Incomplete address - Missing city or postal code
  4. Wrong country - Address in different country
  5. Rural address - Not in geocoding databases
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions","title":"Solutions","text":"

Solution 1: Verify address format

# Good address format (Canadian):\n123 Main Street, Toronto, ON M5H 2N2\n\n# Good address format (US):\n123 Main Street, New York, NY 10001\n\n# Bad formats:\n123 Main  # Missing city/postal\nMain Street  # Missing number\nToronto  # Too vague\n

Solution 2: Test address manually

# Test via Nominatim (no API key needed)\ncurl \"https://nominatim.openstreetmap.org/search?q=123+Main+Street,Toronto,ON&format=json\"\n\n# Should return array with results\n# If empty, address not found\n

Solution 3: Try alternative formats

# If \"123 Main Street, Toronto ON M5H 2N2\" fails, try:\n# - \"123 Main St, Toronto ON M5H2N2\" (no space in postal)\n# - \"123 Main Street, Toronto Ontario M5H 2N2\" (full province)\n# - \"123 Main Street, M5H 2N2\" (postal code only)\n# - \"M5H 2N2\" (postal code geocoding)\n

Solution 4: Check geocoding logs

# View detailed geocoding attempts\ndocker compose logs api | grep \"Geocoding\\|geocode\"\n\n# Shows:\n# Trying provider: google\n# Google geocoding failed: Invalid request\n# Trying provider: nominatim\n# Nominatim geocoding succeeded\n

Solution 5: Manually set coordinates

In admin UI (LocationsPage):

  1. Find location in table
  2. Click Edit
  3. Manually enter lat/lng (from Google Maps)
  4. Save

Or via SQL:

docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"Location\\\" SET latitude = 43.65, longitude = -79.38\n      WHERE address = '123 Main Street';\"\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention","title":"Prevention","text":""},{"location":"v2/troubleshooting/geocoding-issues/#all-providers-failed","title":"All Providers Failed","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_1","title":"Symptoms","text":"
ERROR Geocoding failed: All providers failed for address: \"123 Main St\"\n

All 6 geocoding providers returned no results or errors.

"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_1","title":"Common Causes","text":"
  1. Network issue - Can't reach external APIs
  2. Rate limits - All providers rate limited
  3. Invalid API keys - Google/Mapbox keys invalid
  4. Bad address - Address truly doesn't exist
  5. Provider outages - Services down
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_1","title":"Solutions","text":"

Solution 1: Check network connectivity

# Test DNS resolution\ndocker compose exec api ping -c 3 nominatim.openstreetmap.org\n\n# Test HTTPS connection\ndocker compose exec api curl -I https://nominatim.openstreetmap.org\n\n# If fails, network issue\n

Solution 2: Test each provider manually

# Nominatim (free, no key)\ncurl \"https://nominatim.openstreetmap.org/search?q=123+Main+Street,Toronto&format=json\"\n\n# Google (requires GOOGLE_GEOCODING_API_KEY)\ncurl \"https://maps.googleapis.com/maps/api/geocode/json?address=123+Main+Street,Toronto&key=YOUR_KEY\"\n\n# Mapbox (requires MAPBOX_API_KEY)\ncurl \"https://api.mapbox.com/geocoding/v5/mapbox.places/123+Main+Street,Toronto.json?access_token=YOUR_KEY\"\n

Solution 3: Check API keys

# Verify API keys in .env\ncat .env | grep -E \"GOOGLE_GEOCODING_API_KEY|MAPBOX_API_KEY|HERE_API_KEY\"\n\n# Should show non-empty values\n# If empty, providers requiring keys won't work\n

Solution 4: Check rate limits

# View geocoding stats\ncurl http://localhost:4000/api/map/geocoding/stats \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Shows:\n# {\n#   \"totalAttempts\": 1523,\n#   \"successful\": 1450,\n#   \"failed\": 73,\n#   \"byProvider\": {\n#     \"google\": { \"attempts\": 500, \"successes\": 480 },\n#     \"nominatim\": { \"attempts\": 600, \"successes\": 570 }\n#   }\n# }\n

Solution 5: Wait and retry

Rate limits reset after time: - Nominatim: 1 request/second (resets immediately) - Google: 50 requests/second (resets after 1 second) - Mapbox: 600 requests/minute (resets after 1 minute)

# Retry geocoding after wait\ncurl -X POST http://localhost:4000/api/map/locations/LOCATION_ID/geocode \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_1","title":"Prevention","text":""},{"location":"v2/troubleshooting/geocoding-issues/#low-confidence-results","title":"Low Confidence Results","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_2","title":"Symptoms","text":"

Geocoding succeeds but coordinates seem wrong or imprecise.

Example: - Address: \"123 Main Street, Toronto\" - Geocoded to: Center of Toronto (not specific address)

"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_2","title":"Common Causes","text":"
  1. Ambiguous address - Multiple matches
  2. Incomplete address - Missing street number
  3. Rural address - Only city-level precision
  4. Provider limitation - Provider doesn't have precise data
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_2","title":"Solutions","text":"

Solution 1: Check geocoding confidence

# View location details\ncurl http://localhost:4000/api/map/locations/LOCATION_ID \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Response includes:\n# {\n#   \"geocodingProvider\": \"nominatim\",\n#   \"geocodingConfidence\": \"low\",  # or \"high\", \"medium\"\n#   \"latitude\": 43.65,\n#   \"longitude\": -79.38\n# }\n

Solution 2: Add more detail to address

# Low confidence:\n\"Main Street, Toronto\"\n\n# Higher confidence:\n\"123 Main Street, Toronto, ON M5H 2N2\"\n\n# Best confidence:\n\"123 Main Street, Toronto, Ontario M5H 2N2, Canada\"\n

Solution 3: Use postal code geocoding

For Canadian addresses, postal code is often more accurate:

# Update location with postal code\nUPDATE \"Location\"\nSET \"postalCode\" = 'M5H 2N2'\nWHERE id = 'LOCATION_ID';\n\n# Re-geocode (will use postal code)\ncurl -X POST http://localhost:4000/api/map/locations/LOCATION_ID/geocode \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n

Solution 4: Manually verify on map

In LocationsPage: 1. Click location row 2. View on map 3. If wrong, manually drag marker to correct location 4. Save

Solution 5: Flag for review

# Mark low-confidence results for manual review\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, address, \\\"geocodingConfidence\\\"\n      FROM \\\"Location\\\"\n      WHERE \\\"geocodingConfidence\\\" = 'low'\n      ORDER BY \\\"createdAt\\\" DESC\n      LIMIT 50;\"\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_2","title":"Prevention","text":""},{"location":"v2/troubleshooting/geocoding-issues/#rate-limit-exceeded","title":"Rate Limit Exceeded","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_3","title":"Symptoms","text":"
ERROR Geocoding rate limit exceeded for provider: google\nWARN Retrying with next provider: mapbox\n

Or:

ERROR 429 Too Many Requests from https://maps.googleapis.com/\n
"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_3","title":"Common Causes","text":"
  1. Bulk import - Geocoding thousands of addresses at once
  2. No API key - Free tier has lower limits
  3. Shared IP - Multiple users on same IP
  4. Testing - Repeated manual geocodes
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_3","title":"Solutions","text":"

Solution 1: Check rate limits

Per-provider limits:

Provider Free Tier With API Key Nominatim 1/sec N/A Google N/A 50/sec (or paid limit) Mapbox N/A 600/min ArcGIS 1000/day Varies Photon Unlimited N/A HERE N/A Varies by plan

Solution 2: Use geocoding queue

For bulk operations:

# Queue all ungeocoded locations\ncurl -X POST http://localhost:4000/api/map/locations/queue-geocoding \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"batchSize\": 100}'\n\n# Queue processes at rate-limit-safe speed\n

Solution 3: Add API keys

# In .env\nGOOGLE_GEOCODING_API_KEY=your-key-here\nMAPBOX_API_KEY=your-key-here\n\n# Restart API\ndocker compose restart api\n

Solution 4: Distribute across providers

# Check provider usage\ncurl http://localhost:4000/api/map/geocoding/stats \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# If one provider is overused, system auto-rotates to others\n

Solution 5: Wait and retry

# Wait for rate limit window to reset\n# Nominatim: 1 second\n# Google: Check quota reset time\n# Mapbox: 1 minute\n\n# Retry failed geocodes\ncurl -X POST http://localhost:4000/api/map/locations/retry-failed \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_3","title":"Prevention","text":""},{"location":"v2/troubleshooting/geocoding-issues/#map-display-issues","title":"Map Display Issues","text":""},{"location":"v2/troubleshooting/geocoding-issues/#map-not-loading","title":"Map Not Loading","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_4","title":"Symptoms","text":"

Map container shows blank white/gray box. No tiles loaded.

Browser console:

Error loading tile: https://tile.openstreetmap.org/...\nFailed to load resource: net::ERR_BLOCKED_BY_CLIENT\n

"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_4","title":"Common Causes","text":"
  1. Ad blocker - Blocking OSM tile requests
  2. Network issue - Can't reach tile server
  3. CSP headers - Content Security Policy blocking
  4. Leaflet CSS missing - Styles not imported
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_4","title":"Solutions","text":"

Solution 1: Disable ad blocker

  1. Disable ad blocker for your site
  2. Or whitelist *.openstreetmap.org
  3. Refresh page

Solution 2: Check network

# Test tile server\ncurl -I https://tile.openstreetmap.org/0/0/0.png\n\n# Should return 200 OK\n# If fails, network or DNS issue\n

Solution 3: Verify Leaflet CSS

In map component file:

// Must import Leaflet CSS\nimport 'leaflet/dist/leaflet.css';\n

Check in browser DevTools: - Elements tab \u2192 Check if .leaflet-container has styles - Network tab \u2192 Check if leaflet.css loaded

Solution 4: Check CSP headers

In nginx/conf.d/default.conf:

# Allow OSM tiles\nadd_header Content-Security-Policy \"... img-src 'self' data: https://*.openstreetmap.org;\";\n

Solution 5: Try alternative tile provider

// In map component\n<TileLayer\n  attribution='&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a>'\n  url=\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\"\n  // Or try Carto:\n  // url=\"https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png\"\n/>\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_4","title":"Prevention","text":""},{"location":"v2/troubleshooting/geocoding-issues/#markers-not-appearing","title":"Markers Not Appearing","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_5","title":"Symptoms","text":"

Map loads but location markers don't appear.

"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_5","title":"Common Causes","text":"
  1. No data - No locations fetched
  2. Null coordinates - Locations not geocoded
  3. Out of bounds - Markers outside map view
  4. Rendering error - React component error
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_5","title":"Solutions","text":"

Solution 1: Check data loaded

// In browser console\nconsole.log('Locations:', locations);\n\n// Should show array of locations with lat/lng\n// If empty or undefined, data not loaded\n

Solution 2: Verify coordinates

# Check locations have coordinates\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT COUNT(*) FROM \\\"Location\\\" WHERE latitude IS NOT NULL AND longitude IS NOT NULL;\"\n\n# If 0, no locations geocoded\n

Solution 3: Zoom to markers

// In map component, fit bounds to markers\nuseEffect(() => {\n  if (locations.length > 0 && mapRef.current) {\n    const bounds = locations\n      .filter(l => l.latitude && l.longitude)\n      .map(l => [l.latitude, l.longitude]);\n\n    if (bounds.length > 0) {\n      mapRef.current.fitBounds(bounds, { padding: [50, 50] });\n    }\n  }\n}, [locations]);\n

Solution 4: Check marker rendering

// Verify CircleMarker component\n{locations.map((location) => {\n  if (!location.latitude || !location.longitude) return null;\n\n  return (\n    <CircleMarker\n      key={location.id}\n      center={[location.latitude, location.longitude]}\n      radius={8}\n      // ...\n    />\n  );\n})}\n

Solution 5: Check browser console

Look for React errors:

Warning: Each child in a list should have a unique \"key\" prop\nError: Invalid latitude/longitude\n

"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_5","title":"Prevention","text":""},{"location":"v2/troubleshooting/geocoding-issues/#cuts-not-rendering","title":"Cuts Not Rendering","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_6","title":"Symptoms","text":"

Cut polygons don't appear on map.

"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_6","title":"Common Causes","text":"
  1. Invalid GeoJSON - Malformed polygon data
  2. Wrong coordinate order - GeoJSON uses [lng, lat], Leaflet uses [lat, lng]
  3. Self-intersecting polygon - Invalid polygon geometry
  4. Out of bounds - Polygon outside map view
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_6","title":"Solutions","text":"

Solution 1: Validate GeoJSON

# Check cut geometry\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, name, ST_AsGeoJSON(geometry) FROM \\\"Cut\\\" WHERE id = 'CUT_ID';\"\n\n# Verify format:\n# {\n#   \"type\": \"Polygon\",\n#   \"coordinates\": [[[lng1, lat1], [lng2, lat2], ...]]\n# }\n

Solution 2: Convert coordinates

// GeoJSON uses [lng, lat]\nconst geojson = {\n  type: 'Polygon',\n  coordinates: [[[-79.38, 43.65], [-79.37, 43.65], ...]]\n};\n\n// Convert to Leaflet [lat, lng]\nconst leafletCoords = geojson.coordinates[0].map(([lng, lat]) => [lat, lng]);\n

Solution 3: Check for self-intersection

-- Validate polygon geometry\nSELECT id, name, ST_IsValid(geometry) as is_valid\nFROM \"Cut\"\nWHERE NOT ST_IsValid(geometry);\n\n-- If invalid, show reason\nSELECT id, name, ST_IsValidReason(geometry)\nFROM \"Cut\"\nWHERE NOT ST_IsValid(geometry);\n\n-- Fix with buffer(0)\nUPDATE \"Cut\"\nSET geometry = ST_Buffer(geometry, 0)\nWHERE NOT ST_IsValid(geometry);\n

Solution 4: Zoom to cut

// Fit map to cut bounds\nuseEffect(() => {\n  if (cut?.geometry && mapRef.current) {\n    const coords = cut.geometry.coordinates[0].map(([lng, lat]) => [lat, lng]);\n    const bounds = L.latLngBounds(coords);\n    mapRef.current.fitBounds(bounds, { padding: [50, 50] });\n  }\n}, [cut]);\n

Solution 5: Check Polygon component

// Verify Polygon rendering\n<Polygon\n  positions={coords}  // Array of [lat, lng]\n  pathOptions={{\n    color: '#3498db',\n    fillColor: '#3498db',\n    fillOpacity: 0.2\n  }}\n/>\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_6","title":"Prevention","text":""},{"location":"v2/troubleshooting/geocoding-issues/#gps-not-working","title":"GPS Not Working","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_7","title":"Symptoms","text":"

Geolocate button doesn't work or shows error.

Browser shows permission prompt but location never loads.

"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_7","title":"Common Causes","text":"
  1. HTTPS required - Geolocation API requires HTTPS (or localhost)
  2. Permission denied - User denied location permission
  3. GPS unavailable - Device has no GPS
  4. Browser doesn't support - Old browser
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_7","title":"Solutions","text":"

Solution 1: Check HTTPS

Geolocation API requires: - HTTPS (https://) - OR localhost (http://localhost) - OR 127.0.0.1 (http://127.0.0.1)

# In production, ensure HTTPS\n# Via Pangolin tunnel or Cloudflare\n

Solution 2: Grant permission

  1. Click lock icon in address bar
  2. Location \u2192 Allow
  3. Refresh page
  4. Try geolocate again

Solution 3: Test geolocation API

// In browser console\nnavigator.geolocation.getCurrentPosition(\n  (pos) => console.log('Location:', pos.coords),\n  (err) => console.error('Error:', err)\n);\n\n// Errors:\n// PERMISSION_DENIED - User denied\n// POSITION_UNAVAILABLE - GPS unavailable\n// TIMEOUT - Taking too long\n

Solution 4: Increase timeout

// In geolocate code\nnavigator.geolocation.getCurrentPosition(\n  successCallback,\n  errorCallback,\n  {\n    timeout: 10000,  // 10 seconds (default: 5000)\n    enableHighAccuracy: true,\n    maximumAge: 0\n  }\n);\n

Solution 5: Fallback to IP geolocation

// If GPS fails, use IP-based location\nconst fallbackLocation = async () => {\n  const response = await fetch('https://ipapi.co/json/');\n  const data = await response.json();\n  return {\n    latitude: data.latitude,\n    longitude: data.longitude\n  };\n};\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_7","title":"Prevention","text":""},{"location":"v2/troubleshooting/geocoding-issues/#coordinate-issues","title":"Coordinate Issues","text":""},{"location":"v2/troubleshooting/geocoding-issues/#invalid-latlng","title":"Invalid Lat/Lng","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_8","title":"Symptoms","text":"
Error: Invalid latitude/longitude values\n

Or markers appear in wrong location (ocean, wrong country).

"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_8","title":"Common Causes","text":"
  1. Swapped coordinates - Latitude and longitude reversed
  2. Out of range - Latitude > 90 or Longitude > 180
  3. Wrong sign - Positive instead of negative (or vice versa)
  4. Decimal precision - Too many/few decimal places
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_8","title":"Solutions","text":"

Solution 1: Validate ranges

Valid ranges: - Latitude: -90 to 90 - Longitude: -180 to 180

# Find invalid coordinates\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, address, latitude, longitude\n      FROM \\\"Location\\\"\n      WHERE latitude < -90 OR latitude > 90\n         OR longitude < -180 OR longitude > 180;\"\n

Solution 2: Check coordinate order

# Common mistake: swapped lat/lng\n# Toronto should be:\n# Latitude: 43.65 (positive, North)\n# Longitude: -79.38 (negative, West)\n\n# If showing as 79.38, -43.65, they're swapped\n\n# Fix:\nUPDATE \"Location\"\nSET latitude = longitude, longitude = latitude\nWHERE id = 'LOCATION_ID';\n

Solution 3: Verify hemisphere

For North American locations: - Latitude: Positive (North) - Longitude: Negative (West)

# If US/Canada location has positive longitude, wrong sign\nUPDATE \"Location\"\nSET longitude = longitude * -1\nWHERE country = 'Canada' AND longitude > 0;\n

Solution 4: Check decimal precision

# Good precision (6 decimals \u2248 0.1m accuracy):\nLatitude: 43.651234\nLongitude: -79.381234\n\n# Too few decimals (imprecise):\nLatitude: 43.65\nLongitude: -79.38\n\n# Too many decimals (unnecessary):\nLatitude: 43.651234567890\nLongitude: -79.381234567890\n

Solution 5: Visual verification

  1. Open Google Maps
  2. Enter coordinates: 43.651234, -79.381234
  3. Verify location matches address
  4. If wrong, get correct coordinates from Google Maps
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_8","title":"Prevention","text":""},{"location":"v2/troubleshooting/geocoding-issues/#out-of-bounds-coordinates","title":"Out of Bounds Coordinates","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_9","title":"Symptoms","text":"

Markers appear outside expected area (different country/continent).

"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_9","title":"Solutions","text":"

Solution 1: Set map bounds

// Limit map to expected region\nconst bounds = L.latLngBounds(\n  [41.0, -95.0],  // Southwest corner\n  [50.0, -74.0]   // Northeast corner (covers eastern Canada/US)\n);\n\n<MapContainer\n  maxBounds={bounds}\n  maxBoundsViscosity={1.0}\n  // ...\n/>\n

Solution 2: Filter locations by bounds

// Only show locations in expected region\nconst filteredLocations = locations.filter(location => {\n  return location.latitude >= 41 && location.latitude <= 50 &&\n         location.longitude >= -95 && location.longitude <= -74;\n});\n
"},{"location":"v2/troubleshooting/geocoding-issues/#projection-errors-nar-data","title":"Projection Errors (NAR Data)","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_10","title":"Symptoms","text":"

Locations imported from NAR data appear in wrong place.

"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_9","title":"Common Causes","text":"
  1. Wrong projection - NAR uses EPSG:3347 (Lambert), not WGS84
  2. Missing conversion - Coordinates not converted to lat/lng
  3. Coordinate swap - BG_X and BG_Y reversed
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_10","title":"Solutions","text":"

Solution 1: Verify NAR import uses proj4

In api/src/modules/map/locations/nar-import.service.ts:

import proj4 from 'proj4';\n\n// Define EPSG:3347 (NAR projection)\nproj4.defs('EPSG:3347',\n  '+proj=lcc +lat_0=63.390675 +lon_0=-91.86666666666666 ' +\n  '+lat_1=49 +lat_2=77 +x_0=6200000 +y_0=3000000 ' +\n  '+ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs'\n);\n\n// Convert\nconst [longitude, latitude] = proj4('EPSG:3347', 'WGS84', [bgX, bgY]);\n

Solution 2: Check coordinate order

NAR Address files: - BG_X: Easting (X coordinate in meters) - BG_Y: Northing (Y coordinate in meters)

Conversion order: [BG_X, BG_Y] \u2192 [longitude, latitude]

Solution 3: Verify conversion

# Test conversion manually\ndocker compose exec api node -e \"\nconst proj4 = require('proj4');\nproj4.defs('EPSG:3347', '+proj=lcc +lat_0=63.390675 +lon_0=-91.86666666666666 +lat_1=49 +lat_2=77 +x_0=6200000 +y_0=3000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs');\n\n// Example Toronto coordinates in EPSG:3347:\nconst [lng, lat] = proj4('EPSG:3347', 'WGS84', [6458123, 3534567]);\nconsole.log('Lat:', lat, 'Lng:', lng);\n// Should be approximately: Lat: 43.65 Lng: -79.38\n\"\n

Solution 4: Re-import NAR data

If imported incorrectly:

# Delete bad data\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"DELETE FROM \\\"Location\\\" WHERE \\\"importSource\\\" = 'NAR';\"\n\n# Re-import with correct projection\n# Via admin UI: /app/map/locations \u2192 NAR Import tab\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_9","title":"Prevention","text":""},{"location":"v2/troubleshooting/geocoding-issues/#queue-issues","title":"Queue Issues","text":""},{"location":"v2/troubleshooting/geocoding-issues/#geocoding-queue-stuck","title":"Geocoding Queue Stuck","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_11","title":"Symptoms","text":"

Locations remain ungeocoded even though queue is running.

Queue shows jobs but they never process.

"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_11","title":"Solutions","text":"

Solution 1: Check queue status

# View queue stats\ncurl http://localhost:4000/api/map/geocoding/queue/stats \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Shows:\n# {\n#   \"waiting\": 150,\n#   \"active\": 0,  # Should be > 0 if processing\n#   \"completed\": 2500,\n#   \"failed\": 25\n# }\n

Solution 2: Check worker is running

# Worker should log processing\ndocker compose logs api | grep -i \"geocoding worker\\|processing geocode\"\n\n# Should show:\n# Geocoding worker started\n# Processing geocode job for location: abc-123\n

Solution 3: Restart queue worker

# Restart API (restarts worker)\ndocker compose restart api\n\n# Check worker started\ndocker compose logs api | grep \"Geocoding worker started\"\n

Solution 4: Check Redis connection

# Test Redis\ndocker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD ping\n# Should return: PONG\n\n# Check queue keys\ndocker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD keys \"bull:geocoding:*\"\n

Solution 5: Manually process stuck jobs

# Retry failed jobs\ncurl -X POST http://localhost:4000/api/map/geocoding/queue/retry-failed \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Clean stuck jobs\ncurl -X POST http://localhost:4000/api/map/geocoding/queue/clean \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\"status\": \"failed\", \"grace\": 86400000}'  # Clean failed jobs older than 1 day\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_10","title":"Prevention","text":""},{"location":"v2/troubleshooting/geocoding-issues/#jobs-failing","title":"Jobs Failing","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_12","title":"Symptoms","text":"

Queue shows high failed job count.

Locations remain ungeocoded with error status.

"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_12","title":"Solutions","text":"

Solution 1: View failed jobs

# Get failed job details\ncurl http://localhost:4000/api/map/geocoding/queue/failed \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Shows:\n# [\n#   {\n#     \"id\": \"123\",\n#     \"data\": { \"locationId\": \"abc\", \"address\": \"...\" },\n#     \"failedReason\": \"All providers failed\",\n#     \"attemptsMade\": 3\n#   }\n# ]\n

Solution 2: Check error patterns

# Common failure reasons\ndocker compose logs api | grep \"Geocoding failed\" | sort | uniq -c\n\n# Example output:\n#  45 Geocoding failed: Rate limit exceeded\n#  12 Geocoding failed: No results found\n#   3 Geocoding failed: Network error\n

Solution 3: Retry with different settings

# Retry with longer timeout\ncurl -X POST http://localhost:4000/api/map/locations/LOCATION_ID/geocode \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"timeout\": 30000}'  # 30 seconds\n

Solution 4: Manual intervention

For repeatedly failing addresses:

  1. Open LocationsPage
  2. Find failed locations
  3. Review address (fix typos)
  4. Manually set coordinates if needed
  5. Or delete if invalid
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_11","title":"Prevention","text":""},{"location":"v2/troubleshooting/geocoding-issues/#performance-issues","title":"Performance Issues","text":""},{"location":"v2/troubleshooting/geocoding-issues/#slow-geocoding","title":"Slow Geocoding","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_13","title":"Symptoms","text":"

Geocoding takes 5-10+ seconds per address.

Bulk imports very slow.

"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_13","title":"Solutions","text":"

Solution 1: Use faster providers first

Provider speed (fastest to slowest): 1. Google (with API key) - ~200ms 2. Mapbox (with API key) - ~300ms 3. Nominatim - ~500ms 4. ArcGIS - ~800ms 5. Photon - ~1000ms 6. HERE - ~400ms

Configure in api/src/modules/map/geocoding/geocoding.service.ts.

Solution 2: Increase concurrency

In geocoding queue worker:

// Increase concurrent geocoding\nconst worker = new Worker('geocoding', processor, {\n  concurrency: 5,  // Process 5 at a time (default: 1)\n  limiter: {\n    max: 50,  // Max 50 jobs per second\n    duration: 1000\n  }\n});\n

Solution 3: Use bulk geocoding APIs

Some providers offer batch geocoding:

# Google Batch Geocoding (requires Business plan)\n# Can geocode up to 100 addresses in one request\n

Solution 4: Cache results

// Cache geocoding results in Redis\nconst cacheKey = `geocode:${address}`;\nconst cached = await redis.get(cacheKey);\n\nif (cached) {\n  return JSON.parse(cached);\n}\n\nconst result = await geocode(address);\nawait redis.setex(cacheKey, 86400, JSON.stringify(result));  // Cache 24h\nreturn result;\n

Solution 5: Parallel processing

// Geocode multiple addresses in parallel\nconst addresses = ['123 Main St', '456 Oak Ave', ...];\n\nconst results = await Promise.all(\n  addresses.map(address => geocode(address))\n);\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_12","title":"Prevention","text":""},{"location":"v2/troubleshooting/geocoding-issues/#too-many-api-calls","title":"Too Many API Calls","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_14","title":"Symptoms","text":"

High API usage on Google/Mapbox.

Approaching or exceeding quota.

"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_14","title":"Solutions","text":"

Solution 1: Monitor usage

# Check geocoding stats\ncurl http://localhost:4000/api/map/geocoding/stats \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Track API costs:\n# Google: $5 per 1000 requests (after 40k free/month)\n# Mapbox: $0.50 per 1000 requests (after 100k free/month)\n

Solution 2: Use free providers first

Reorder provider priority:

// In geocodingService.ts\nconst providers = [\n  'nominatim',  // Free (try first)\n  'photon',     // Free\n  'arcgis',     // Free (1000/day)\n  'google',     // Paid (use only if others fail)\n  'mapbox',     // Paid\n  'here'        // Paid\n];\n

Solution 3: Cache aggressively

// Cache geocoding results permanently\nconst cacheKey = `geocode:${normalizeAddress(address)}`;\nconst cached = await redis.get(cacheKey);\n\nif (cached) {\n  return JSON.parse(cached);\n}\n\nconst result = await geocode(address);\nawait redis.set(cacheKey, JSON.stringify(result));  // No expiration\nreturn result;\n

Solution 4: Deduplicate requests

# Before geocoding, check if address already geocoded\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT latitude, longitude FROM \\\"Location\\\"\n      WHERE LOWER(address) = LOWER('123 Main Street, Toronto')\n        AND latitude IS NOT NULL\n      LIMIT 1;\"\n\n# If exists, copy coordinates instead of geocoding again\n

Solution 5: Set quota alerts

In Google Cloud Console: 1. Navigate to Geocoding API 2. Set quota alerts (e.g., 80% of limit) 3. Receive email before exceeding quota

"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_13","title":"Prevention","text":""},{"location":"v2/troubleshooting/geocoding-issues/#data-quality","title":"Data Quality","text":""},{"location":"v2/troubleshooting/geocoding-issues/#duplicate-locations","title":"Duplicate Locations","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_15","title":"Symptoms","text":"

Same address appears multiple times in locations table.

"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_15","title":"Solutions","text":"

Solution 1: Find duplicates

# Find duplicate addresses\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT address, COUNT(*), array_agg(id)\n      FROM \\\"Location\\\"\n      GROUP BY LOWER(address)\n      HAVING COUNT(*) > 1;\"\n

Solution 2: Merge duplicates

# Keep oldest, delete newer\n# (After reassigning foreign keys to kept record)\nDELETE FROM \"Location\" AS l1\nWHERE EXISTS (\n  SELECT 1 FROM \"Location\" AS l2\n  WHERE LOWER(l2.address) = LOWER(l1.address)\n    AND l2.\"createdAt\" < l1.\"createdAt\"\n);\n

Solution 3: Add unique constraint

model Location {\n  id      String @id @default(uuid())\n  address String\n\n  @@unique([address])  // Prevent duplicates\n}\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_14","title":"Prevention","text":""},{"location":"v2/troubleshooting/geocoding-issues/#ungeocoded-locations","title":"Ungeocoded Locations","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_16","title":"Symptoms","text":"

Many locations with null latitude/longitude.

"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_16","title":"Solutions","text":"

Solution 1: Count ungeocoded

docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT COUNT(*) FROM \\\"Location\\\" WHERE latitude IS NULL;\"\n

Solution 2: Queue all ungeocoded

# Via API\ncurl -X POST http://localhost:4000/api/map/locations/queue-geocoding \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Queues all locations with null coordinates\n

Solution 3: View on Data Quality Dashboard

Navigate to /app/map/data-quality:

Solution 4: Export ungeocoded for manual review

# Export to CSV\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"COPY (SELECT id, address, city, \\\"postalCode\\\" FROM \\\"Location\\\"\n            WHERE latitude IS NULL) TO STDOUT WITH CSV HEADER\" > ungeocoded.csv\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_15","title":"Prevention","text":""},{"location":"v2/troubleshooting/geocoding-issues/#useful-commands","title":"Useful Commands","text":""},{"location":"v2/troubleshooting/geocoding-issues/#geocoding-operations","title":"Geocoding Operations","text":"
# Geocode single location\ncurl -X POST http://localhost:4000/api/map/locations/LOCATION_ID/geocode \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Bulk geocode via queue\ncurl -X POST http://localhost:4000/api/map/locations/queue-geocoding \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Check geocoding stats\ncurl http://localhost:4000/api/map/geocoding/stats \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Retry failed geocodes\ncurl -X POST http://localhost:4000/api/map/geocoding/queue/retry-failed \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n
"},{"location":"v2/troubleshooting/geocoding-issues/#database-queries","title":"Database Queries","text":"
# Count by geocoding status\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT\n        COUNT(*) FILTER (WHERE latitude IS NOT NULL) as geocoded,\n        COUNT(*) FILTER (WHERE latitude IS NULL) as ungeocoded\n      FROM \\\"Location\\\";\"\n\n# List ungeocoded\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, address FROM \\\"Location\\\"\n      WHERE latitude IS NULL\n      LIMIT 50;\"\n\n# Geocoding provider stats\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT \\\"geocodingProvider\\\", COUNT(*)\n      FROM \\\"Location\\\"\n      WHERE \\\"geocodingProvider\\\" IS NOT NULL\n      GROUP BY \\\"geocodingProvider\\\";\"\n
"},{"location":"v2/troubleshooting/geocoding-issues/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/geocoding-issues/#geocoding-documentation","title":"Geocoding Documentation","text":""},{"location":"v2/troubleshooting/geocoding-issues/#other-troubleshooting","title":"Other Troubleshooting","text":""},{"location":"v2/troubleshooting/geocoding-issues/#external-resources","title":"External Resources","text":"

Last Updated: February 2026 Version: V2.0 Status: Complete

"},{"location":"v2/troubleshooting/monitoring-issues/","title":"Monitoring and Observability Issues","text":"

This guide covers Prometheus, Grafana, and observability stack problems in Changemaker Lite V2.

"},{"location":"v2/troubleshooting/monitoring-issues/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/monitoring-issues/#monitoring-stack","title":"Monitoring Stack","text":"

Changemaker Lite V2 uses profile-based monitoring (optional):

# Start with monitoring\ndocker compose --profile monitoring up -d\n

Components:

"},{"location":"v2/troubleshooting/monitoring-issues/#custom-metrics","title":"Custom Metrics","text":"

12 custom cm_* Prometheus metrics:

  1. cm_api_uptime_seconds - API uptime
  2. cm_database_uptime_seconds - Database uptime
  3. cm_email_queue_size - Email queue depth
  4. cm_geocoding_queue_size - Geocoding queue depth
  5. cm_users_total - Total users
  6. cm_campaigns_total - Total campaigns
  7. cm_locations_total - Total locations
  8. cm_geocoded_locations_total - Geocoded locations
  9. cm_active_canvass_sessions - Active sessions
  10. cm_external_service_up - Service health (0/1)
  11. cm_listmonk_subscribers_total - Listmonk subscribers
  12. cm_media_videos_total - Total videos

Plus standard HTTP metrics: - http_request_duration_seconds - http_requests_total

"},{"location":"v2/troubleshooting/monitoring-issues/#prometheus-not-scraping","title":"Prometheus Not Scraping","text":""},{"location":"v2/troubleshooting/monitoring-issues/#target-down","title":"Target Down","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms","title":"Symptoms","text":"

Prometheus UI (localhost:9090) shows targets as \"DOWN\":

Target: api (localhost:4000/metrics)\nState: DOWN\nError: Get \"http://api:4000/metrics\": connection refused\n

No data in Grafana dashboards.

"},{"location":"v2/troubleshooting/monitoring-issues/#common-causes","title":"Common Causes","text":"
  1. Service not running - API container stopped
  2. Metrics endpoint missing - /metrics endpoint not registered
  3. Network issue - Prometheus can't reach service
  4. Authentication required - Metrics endpoint requires auth
"},{"location":"v2/troubleshooting/monitoring-issues/#solutions","title":"Solutions","text":"

Solution 1: Check service is running

# Is API running?\ndocker compose ps api\n\n# Should show \"Up\"\n# If not:\ndocker compose up -d api\n

Solution 2: Test metrics endpoint

# From host\ncurl http://localhost:4000/metrics\n\n# Should return Prometheus metrics:\n# # HELP cm_api_uptime_seconds API uptime in seconds\n# # TYPE cm_api_uptime_seconds gauge\n# cm_api_uptime_seconds 123.45\n\n# From Prometheus container\ndocker compose exec prometheus wget -O- http://api:4000/metrics\n

Solution 3: Check Prometheus config

In configs/prometheus/prometheus.yml:

scrape_configs:\n  - job_name: 'api'\n    static_configs:\n      - targets: ['api:4000']  # Use service name, not localhost\n

Solution 4: Verify network

# Both on same network?\ndocker inspect changemaker-lite-prometheus-1 | grep NetworkMode\ndocker inspect changemaker-lite-api-1 | grep NetworkMode\n\n# Should both show \"changemaker-lite\"\n

Solution 5: Check metrics are registered

In API logs:

docker compose logs api | grep -i \"metrics\\|prometheus\"\n\n# Should show:\n# Metrics endpoint registered at /metrics\n# Prometheus metrics initialized\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention","title":"Prevention","text":""},{"location":"v2/troubleshooting/monitoring-issues/#scrape-timeout","title":"Scrape Timeout","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_1","title":"Symptoms","text":"
Target: api\nState: UP\nLast Scrape: 5.2s (slow)\nLast Error: context deadline exceeded\n

Scrapes taking too long or timing out.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_1","title":"Solutions","text":"

Solution 1: Increase scrape timeout

In configs/prometheus/prometheus.yml:

global:\n  scrape_interval: 15s\n  scrape_timeout: 10s  # Increase from 10s to 30s\n\nscrape_configs:\n  - job_name: 'api'\n    scrape_interval: 30s  # Scrape less frequently\n    scrape_timeout: 20s\n    static_configs:\n      - targets: ['api:4000']\n

Reload config:

# Reload Prometheus config\ndocker compose exec prometheus kill -HUP 1\n\n# Or restart\ndocker compose restart prometheus\n

Solution 2: Optimize metrics generation

// In api/src/utils/metrics.ts\n// Cache expensive metrics\nlet cachedUserCount = 0;\nlet lastUserCountUpdate = 0;\n\nregister.registerMetric(new Gauge({\n  name: 'cm_users_total',\n  help: 'Total number of users',\n  async collect() {\n    const now = Date.now();\n    // Only query database every 60 seconds\n    if (now - lastUserCountUpdate > 60000) {\n      cachedUserCount = await prisma.user.count();\n      lastUserCountUpdate = now;\n    }\n    this.set(cachedUserCount);\n  }\n}));\n

Solution 3: Reduce metric cardinality

// Bad - high cardinality (creates metric per user)\nnew Counter({\n  name: 'requests_by_user',\n  labelNames: ['userId']  // Don't do this!\n});\n\n// Good - low cardinality\nnew Counter({\n  name: 'requests_by_role',\n  labelNames: ['role']  // Only 5 roles\n});\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_1","title":"Prevention","text":""},{"location":"v2/troubleshooting/monitoring-issues/#authentication-errors","title":"Authentication Errors","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_2","title":"Symptoms","text":"
Error: 401 Unauthorized when scraping /metrics\n
"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_2","title":"Solutions","text":"

Changemaker Lite V2 metrics endpoint is public (no auth required).

If you see auth errors:

Solution 1: Remove auth middleware from /metrics

In api/src/server.ts:

// Metrics endpoint should be BEFORE authenticate middleware\napp.get('/metrics', async (req, res) => {\n  res.set('Content-Type', register.contentType);\n  res.end(await register.metrics());\n});\n\n// Auth middleware comes after\napp.use(authenticate);\n

Solution 2: Configure basic auth in Prometheus

If you DO want to protect /metrics:

In configs/prometheus/prometheus.yml:

scrape_configs:\n  - job_name: 'api'\n    static_configs:\n      - targets: ['api:4000']\n    basic_auth:\n      username: 'prometheus'\n      password: 'your-password'\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_2","title":"Prevention","text":""},{"location":"v2/troubleshooting/monitoring-issues/#grafana-issues","title":"Grafana Issues","text":""},{"location":"v2/troubleshooting/monitoring-issues/#dashboards-not-loading","title":"Dashboards Not Loading","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_3","title":"Symptoms","text":"

Grafana shows blank dashboards or \"No data\" panels.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_3","title":"Solutions","text":"

Solution 1: Check Grafana is running

docker compose --profile monitoring ps grafana\n\n# Should show \"Up\"\n# If not:\ndocker compose --profile monitoring up -d grafana\n

Solution 2: Verify Prometheus datasource

  1. Open Grafana: http://localhost:3001
  2. Login (admin/admin)
  3. Settings \u2192 Data Sources
  4. Click Prometheus
  5. URL should be: http://prometheus:9090
  6. Click \"Save & Test\"
  7. Should show \"Data source is working\"

Solution 3: Check dashboard provisioning

# List provisioned dashboards\ndocker compose exec grafana ls -la /etc/grafana/provisioning/dashboards/\n\n# Should show:\n# dashboard-provider.yml\n# changemaker-api.json\n# changemaker-queue.json\n# changemaker-external-services.json\n

Solution 4: Import dashboard manually

If auto-provisioning fails:

  1. Grafana \u2192 Dashboards \u2192 Import
  2. Upload JSON from configs/grafana/dashboards/
  3. Select Prometheus datasource
  4. Click Import

Solution 5: Check for data

# Test query in Grafana Explore\n# Query: cm_api_uptime_seconds\n\n# Or test in Prometheus:\ncurl 'http://localhost:9090/api/v1/query?query=cm_api_uptime_seconds'\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_3","title":"Prevention","text":""},{"location":"v2/troubleshooting/monitoring-issues/#datasource-errors","title":"Datasource Errors","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_4","title":"Symptoms","text":"
Error: Failed to query Prometheus\nError: connection refused\n

Red error bars on Grafana panels.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_4","title":"Solutions","text":"

Solution 1: Test Prometheus connection

# From Grafana container\ndocker compose exec grafana wget -O- http://prometheus:9090/api/v1/query?query=up\n\n# Should return JSON:\n# {\"status\":\"success\",\"data\":{\"resultType\":\"vector\",\"result\":[...]}}\n

Solution 2: Check Prometheus is running

docker compose --profile monitoring ps prometheus\n\n# Should show \"Up\"\n

Solution 3: Verify datasource URL

In Grafana datasource settings: - URL: http://prometheus:9090 (NOT http://localhost:9090) - Access: Server (NOT Browser)

Solution 4: Check Docker network

# Same network?\ndocker inspect changemaker-lite-grafana-1 | grep NetworkMode\ndocker inspect changemaker-lite-prometheus-1 | grep NetworkMode\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_4","title":"Prevention","text":""},{"location":"v2/troubleshooting/monitoring-issues/#query-errors","title":"Query Errors","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_5","title":"Symptoms","text":"
Error executing query: parse error at char X: unexpected identifier\n

Panel shows \"Error loading data\".

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_5","title":"Solutions","text":"

Solution 1: Validate PromQL syntax

Common errors:

# Bad - missing {}\ncm_api_uptime_seconds{job=api}\n\n# Good\ncm_api_uptime_seconds{job=\"api\"}\n\n# Bad - wrong function\naverage(cm_api_uptime_seconds)\n\n# Good\navg(cm_api_uptime_seconds)\n

Solution 2: Test query in Explore

  1. Grafana \u2192 Explore
  2. Enter query
  3. Run
  4. Fix errors before adding to dashboard

Solution 3: Check metric exists

# List all metrics\ncurl http://localhost:9090/api/v1/label/__name__/values | jq\n\n# Search for metric\ncurl http://localhost:9090/api/v1/label/__name__/values | jq '.data[]' | grep cm_\n

Solution 4: Use metric browser

In Grafana query editor: 1. Click \"Metrics\" button 2. Browse available metrics 3. Select metric (auto-fills query)

"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_5","title":"Prevention","text":""},{"location":"v2/troubleshooting/monitoring-issues/#alertmanager-issues","title":"Alertmanager Issues","text":""},{"location":"v2/troubleshooting/monitoring-issues/#alerts-not-firing","title":"Alerts Not Firing","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_6","title":"Symptoms","text":"

Conditions met but alert not triggering.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_6","title":"Solutions","text":"

Solution 1: Check alert rules

In Prometheus UI (localhost:9090):

  1. Click \"Alerts\"
  2. Find your alert
  3. Check state:
  4. Inactive: Condition not met
  5. Pending: Met but waiting for for: duration
  6. Firing: Alert active

Solution 2: Verify alert rule syntax

In configs/prometheus/alerts.yml:

groups:\n  - name: changemaker_alerts\n    interval: 30s\n    rules:\n      - alert: APIDown\n        expr: up{job=\"api\"} == 0\n        for: 1m  # Must be down for 1 minute before firing\n        labels:\n          severity: critical\n        annotations:\n          summary: \"API is down\"\n          description: \"API has been down for 1 minute\"\n

Solution 3: Check Alertmanager config

# Test Alertmanager\ncurl http://localhost:9093/api/v1/alerts\n\n# Should return alert list\n

Solution 4: View Prometheus logs

docker compose logs prometheus | grep -i alert\n\n# Shows:\n# Loaded alert rules\n# Alert X is firing\n

Solution 5: Reload alert rules

# Reload Prometheus config\ndocker compose exec prometheus kill -HUP 1\n\n# Check rules loaded\ncurl http://localhost:9090/api/v1/rules\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_6","title":"Prevention","text":""},{"location":"v2/troubleshooting/monitoring-issues/#notifications-not-sent","title":"Notifications Not Sent","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_7","title":"Symptoms","text":"

Alert firing in Prometheus but no notification received.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_7","title":"Solutions","text":"

Solution 1: Check Alertmanager config

In configs/alertmanager/alertmanager.yml:

route:\n  receiver: 'email'\n  group_wait: 30s\n  group_interval: 5m\n  repeat_interval: 12h\n\nreceivers:\n  - name: 'email'\n    email_configs:\n      - to: 'alerts@example.com'\n        from: 'alertmanager@example.com'\n        smarthost: 'smtp.gmail.com:587'\n        auth_username: 'your-email@gmail.com'\n        auth_password: 'your-app-password'\n

Solution 2: Test Alertmanager notification

# Send test alert\ncurl -X POST http://localhost:9093/api/v1/alerts \\\n  -H 'Content-Type: application/json' \\\n  -d '[{\n    \"labels\": {\n      \"alertname\": \"Test\",\n      \"severity\": \"critical\"\n    },\n    \"annotations\": {\n      \"summary\": \"Test alert\"\n    }\n  }]'\n\n# Check if notification sent\ndocker compose logs alertmanager | grep -i \"notification\\|email\"\n

Solution 3: Check SMTP config

See Email Issues for SMTP troubleshooting.

Solution 4: Use alternative notification channels

receivers:\n  - name: 'slack'\n    slack_configs:\n      - api_url: 'https://hooks.slack.com/services/...'\n        channel: '#alerts'\n\n  - name: 'webhook'\n    webhook_configs:\n      - url: 'http://your-webhook-url.com/alerts'\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_7","title":"Prevention","text":""},{"location":"v2/troubleshooting/monitoring-issues/#routing-errors","title":"Routing Errors","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_8","title":"Symptoms","text":"

Alerts going to wrong receiver or being silenced incorrectly.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_8","title":"Solutions","text":"

Solution 1: Check routing rules

In configs/alertmanager/alertmanager.yml:

route:\n  receiver: 'default'\n  routes:\n    - match:\n        severity: critical\n      receiver: 'pager'\n    - match:\n        severity: warning\n      receiver: 'email'\n

Solution 2: Test routing

# Use amtool to test routing\ndocker compose exec alertmanager amtool config routes test \\\n  --config.file=/etc/alertmanager/alertmanager.yml \\\n  alertname=TestAlert severity=critical\n\n# Shows which receiver will be used\n

Solution 3: View active silences

In Alertmanager UI (localhost:9093):

  1. Click \"Silences\"
  2. Check if alert is silenced
  3. Expire or delete silence if wrong

Solution 4: Check inhibition rules

inhibit_rules:\n  - source_match:\n      severity: critical\n    target_match:\n      severity: warning\n    equal: ['alertname', 'instance']\n# Critical alerts inhibit warnings for same instance\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_8","title":"Prevention","text":""},{"location":"v2/troubleshooting/monitoring-issues/#metrics-issues","title":"Metrics Issues","text":""},{"location":"v2/troubleshooting/monitoring-issues/#missing-metrics","title":"Missing Metrics","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_9","title":"Symptoms","text":"

Expected metric not appearing in Prometheus or Grafana.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_9","title":"Solutions","text":"

Solution 1: Check metric is registered

In API code (api/src/utils/metrics.ts):

import { Counter } from 'prom-client';\n\nconst requestCounter = new Counter({\n  name: 'cm_my_metric_total',\n  help: 'Description of metric'\n});\n\nregister.registerMetric(requestCounter);  // Must register!\n

Solution 2: Check metric is collected

# Test /metrics endpoint\ncurl http://localhost:4000/metrics | grep cm_my_metric\n\n# Should show:\n# # HELP cm_my_metric_total Description of metric\n# # TYPE cm_my_metric_total counter\n# cm_my_metric_total 42\n

Solution 3: Check scrape config

In configs/prometheus/prometheus.yml:

scrape_configs:\n  - job_name: 'api'\n    static_configs:\n      - targets: ['api:4000']\n    metric_relabel_configs:  # Don't accidentally drop metric\n      - source_labels: [__name__]\n        regex: 'cm_.*'  # Keep cm_* metrics\n        action: keep\n

Solution 4: Verify metric type

// Counter - only increases (counts)\nconst counter = new Counter({ name: 'cm_requests_total' });\ncounter.inc();  // Increment\n\n// Gauge - can go up or down (current value)\nconst gauge = new Gauge({ name: 'cm_queue_size' });\ngauge.set(42);  // Set value\n\n// Histogram - distribution of values\nconst histogram = new Histogram({ name: 'cm_request_duration_seconds' });\nhistogram.observe(0.5);  // Record duration\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_9","title":"Prevention","text":""},{"location":"v2/troubleshooting/monitoring-issues/#incorrect-values","title":"Incorrect Values","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_10","title":"Symptoms","text":"

Metric showing wrong or unexpected values.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_10","title":"Solutions","text":"

Solution 1: Check metric logic

// Wrong - gauge not updated\nconst gauge = new Gauge({ name: 'cm_users_total' });\n// Never set, always 0\n\n// Right - gauge updated\nconst gauge = new Gauge({\n  name: 'cm_users_total',\n  async collect() {\n    const count = await prisma.user.count();\n    this.set(count);\n  }\n});\n

Solution 2: Check metric type

// Wrong - using Counter for value that can decrease\nconst queueSize = new Counter({ name: 'cm_queue_size' });\nqueueSize.inc(50);  // Add 50\nqueueSize.inc(-20);  // Try to subtract 20 - ERROR!\n\n// Right - use Gauge\nconst queueSize = new Gauge({ name: 'cm_queue_size' });\nqueueSize.set(50);  // Set to 50\nqueueSize.set(30);  // Set to 30 (can decrease)\n

Solution 3: Check label values

// Labels must match exactly\nconst counter = new Counter({\n  name: 'requests_total',\n  labelNames: ['method', 'status']\n});\n\ncounter.inc({ method: 'GET', status: '200' });\n// Creates: requests_total{method=\"GET\",status=\"200\"} 1\n\ncounter.inc({ method: 'GET', status: 200 });  // Wrong - number not string\n// Creates separate metric: requests_total{method=\"GET\",status=200} 1\n

Solution 4: Check query aggregation

# Wrong - sums across all labels\nsum(cm_requests_total)\n\n# Right - sum by specific label\nsum by (status) (cm_requests_total)\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_10","title":"Prevention","text":""},{"location":"v2/troubleshooting/monitoring-issues/#stale-metrics","title":"Stale Metrics","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_11","title":"Symptoms","text":"

Metric values not updating, showing old data.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_11","title":"Solutions","text":"

Solution 1: Check collection frequency

// Metrics only updated when scraped\nconst gauge = new Gauge({\n  name: 'cm_queue_size',\n  async collect() {\n    // This runs on every Prometheus scrape (every 15s)\n    const size = await getQueueSize();\n    this.set(size);\n  }\n});\n

Solution 2: Force metric update

// Update metric on event, not just scrape\neventEmitter.on('queueSizeChanged', (size) => {\n  queueSizeGauge.set(size);\n});\n

Solution 3: Check scrape interval

In configs/prometheus/prometheus.yml:

global:\n  scrape_interval: 15s  # Scrape every 15 seconds\n\n# Increase for more frequent updates\nglobal:\n  scrape_interval: 5s  # Scrape every 5 seconds\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_11","title":"Prevention","text":""},{"location":"v2/troubleshooting/monitoring-issues/#performance-issues","title":"Performance Issues","text":""},{"location":"v2/troubleshooting/monitoring-issues/#high-memory-usage","title":"High Memory Usage","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_12","title":"Symptoms","text":"

Prometheus container using excessive memory (multiple GB).

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_12","title":"Solutions","text":"

Solution 1: Reduce retention period

In docker-compose.yml:

prometheus:\n  command:\n    - '--config.file=/etc/prometheus/prometheus.yml'\n    - '--storage.tsdb.retention.time=7d'  # Reduce from 15d to 7d\n    - '--storage.tsdb.retention.size=10GB'  # Add size limit\n

Restart:

docker compose --profile monitoring restart prometheus\n

Solution 2: Reduce metric cardinality

// Bad - creates metric per user (thousands)\nnew Counter({\n  name: 'requests_by_user',\n  labelNames: ['userId']\n});\n\n// Good - creates metric per role (5)\nnew Counter({\n  name: 'requests_by_role',\n  labelNames: ['role']\n});\n

Solution 3: Drop unnecessary metrics

In configs/prometheus/prometheus.yml:

scrape_configs:\n  - job_name: 'api'\n    static_configs:\n      - targets: ['api:4000']\n    metric_relabel_configs:\n      # Drop metrics we don't use\n      - source_labels: [__name__]\n        regex: 'go_.*|process_.*'  # Drop Go/process metrics\n        action: drop\n

Solution 4: Increase memory limit

prometheus:\n  deploy:\n    resources:\n      limits:\n        memory: 4G  # Increase from 2G\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_12","title":"Prevention","text":""},{"location":"v2/troubleshooting/monitoring-issues/#slow-queries","title":"Slow Queries","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_13","title":"Symptoms","text":"

Grafana dashboards slow to load. Queries taking 10+ seconds.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_13","title":"Solutions","text":"

Solution 1: Optimize query

# Slow - calculates rate for all time\nrate(cm_requests_total[1y])\n\n# Fast - only last 5 minutes\nrate(cm_requests_total[5m])\n\n# Slow - many time series\nsum(rate(cm_requests_total[5m]))\n\n# Faster - aggregate before rate\nsum(increase(cm_requests_total[5m])) / 300\n

Solution 2: Use recording rules

In configs/prometheus/alerts.yml:

groups:\n  - name: recording_rules\n    interval: 30s\n    rules:\n      # Pre-calculate expensive query every 30s\n      - record: job:cm_request_rate:sum\n        expr: sum(rate(cm_requests_total[5m])) by (job)\n\n# Then use in dashboard:\n# job:cm_request_rate:sum  # Fast!\n

Solution 3: Reduce time range

In Grafana: - Change dashboard time range from \"Last 30 days\" to \"Last 24 hours\" - Queries are faster with less data

Solution 4: Increase Prometheus resources

prometheus:\n  deploy:\n    resources:\n      limits:\n        cpus: '2.0'  # More CPU for queries\n        memory: 4G\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_13","title":"Prevention","text":""},{"location":"v2/troubleshooting/monitoring-issues/#useful-commands","title":"Useful Commands","text":""},{"location":"v2/troubleshooting/monitoring-issues/#prometheus-operations","title":"Prometheus Operations","text":"
# Check targets\ncurl http://localhost:9090/api/v1/targets\n\n# Query metric\ncurl 'http://localhost:9090/api/v1/query?query=cm_api_uptime_seconds'\n\n# Query range\ncurl 'http://localhost:9090/api/v1/query_range?query=cm_api_uptime_seconds&start=2026-02-13T00:00:00Z&end=2026-02-13T23:59:59Z&step=15s'\n\n# Reload config\ndocker compose exec prometheus kill -HUP 1\n\n# Check config\ndocker compose exec prometheus promtool check config /etc/prometheus/prometheus.yml\n\n# Check rules\ndocker compose exec prometheus promtool check rules /etc/prometheus/alerts.yml\n
"},{"location":"v2/troubleshooting/monitoring-issues/#grafana-operations","title":"Grafana Operations","text":"
# Test datasource\ncurl http://admin:admin@localhost:3001/api/datasources/1/health\n\n# List dashboards\ncurl http://admin:admin@localhost:3001/api/search?type=dash-db\n\n# Export dashboard\ncurl http://admin:admin@localhost:3001/api/dashboards/uid/YOUR_UID | jq .dashboard > dashboard.json\n\n# Import dashboard\ncurl -X POST http://admin:admin@localhost:3001/api/dashboards/db \\\n  -H \"Content-Type: application/json\" \\\n  -d @dashboard.json\n
"},{"location":"v2/troubleshooting/monitoring-issues/#alertmanager-operations","title":"Alertmanager Operations","text":"
# Check alerts\ncurl http://localhost:9093/api/v1/alerts\n\n# Send test alert\ncurl -X POST http://localhost:9093/api/v1/alerts \\\n  -H 'Content-Type: application/json' \\\n  -d '[{\"labels\":{\"alertname\":\"Test\",\"severity\":\"critical\"},\"annotations\":{\"summary\":\"Test\"}}]'\n\n# List silences\ncurl http://localhost:9093/api/v1/silences\n\n# Create silence\ncurl -X POST http://localhost:9093/api/v1/silences \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"matchers\":[{\"name\":\"alertname\",\"value\":\"Test\"}],\"startsAt\":\"2026-02-13T00:00:00Z\",\"endsAt\":\"2026-02-14T00:00:00Z\",\"createdBy\":\"admin\",\"comment\":\"Test silence\"}'\n
"},{"location":"v2/troubleshooting/monitoring-issues/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/monitoring-issues/#monitoring-documentation","title":"Monitoring Documentation","text":""},{"location":"v2/troubleshooting/monitoring-issues/#other-troubleshooting","title":"Other Troubleshooting","text":""},{"location":"v2/troubleshooting/monitoring-issues/#external-resources","title":"External Resources","text":"

Last Updated: February 2026 Version: V2.0 Status: Complete

"},{"location":"v2/troubleshooting/performance-optimization/","title":"Performance Optimization","text":"

This guide covers performance tuning and optimization strategies for Changemaker Lite V2.

"},{"location":"v2/troubleshooting/performance-optimization/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/performance-optimization/#performance-areas","title":"Performance Areas","text":"
  1. Database - Query optimization, indexing, connection pooling
  2. API - Caching, rate limiting, pagination
  3. Frontend - Code splitting, lazy loading, bundling
  4. Docker - Resource limits, multi-stage builds
  5. Nginx - Compression, caching, keep-alive
  6. Email Queue - Worker count, batch processing
  7. Monitoring - Prometheus metrics, Grafana dashboards
"},{"location":"v2/troubleshooting/performance-optimization/#performance-metrics","title":"Performance Metrics","text":"

Target performance:

"},{"location":"v2/troubleshooting/performance-optimization/#database-optimization","title":"Database Optimization","text":""},{"location":"v2/troubleshooting/performance-optimization/#index-optimization","title":"Index Optimization","text":"

Find missing indexes:

-- Find tables without indexes\nSELECT schemaname, tablename, indexname\nFROM pg_indexes\nWHERE schemaname = 'public'\nORDER BY tablename;\n\n-- Find columns used in WHERE but not indexed\nSELECT *\nFROM pg_stat_user_tables\nWHERE schemaname = 'public'\n  AND seq_scan > 1000\n  AND seq_tup_read / seq_scan > 10000\nORDER BY seq_scan DESC;\n

Add indexes to frequently queried columns:

model Location {\n  id         String @id @default(uuid())\n  address    String\n  city       String\n  province   String\n  postalCode String\n  createdAt  DateTime @default(now())\n\n  // Add indexes for WHERE clauses\n  @@index([postalCode])  // WHERE postalCode = '...'\n  @@index([city])        // WHERE city = '...'\n  @@index([province])    // WHERE province = '...'\n  @@index([createdAt])   // ORDER BY createdAt\n\n  // Composite index for multi-column queries\n  @@index([province, city])  // WHERE province = '...' AND city = '...'\n}\n

Create migration:

docker compose exec api npx prisma migrate dev --name add_location_indexes\n

Verify index usage:

EXPLAIN ANALYZE\nSELECT * FROM \"Location\"\nWHERE \"postalCode\" = 'M5H 2N2';\n\n-- Should show:\n-- Index Scan using Location_postalCode_idx\n-- NOT: Seq Scan on \"Location\"\n
"},{"location":"v2/troubleshooting/performance-optimization/#query-optimization","title":"Query Optimization","text":"

Use select instead of fetching all fields:

// Bad - fetches all fields\nconst users = await prisma.user.findMany();\n// Returns: id, email, password, name, role, createdAt, updatedAt, ...\n\n// Good - only needed fields\nconst users = await prisma.user.findMany({\n  select: {\n    id: true,\n    email: true,\n    name: true,\n    role: true\n  }\n});\n

Use include instead of separate queries:

// Bad - N+1 queries\nconst campaigns = await prisma.campaign.findMany();\nfor (const campaign of campaigns) {\n  const emails = await prisma.campaignEmail.findMany({\n    where: { campaignId: campaign.id }\n  });\n  campaign.emails = emails;\n}\n\n// Good - single query with join\nconst campaigns = await prisma.campaign.findMany({\n  include: {\n    emails: true\n  }\n});\n

Paginate large result sets:

// Bad - fetch all\nconst locations = await prisma.location.findMany();\n// Returns 10,000+ rows\n\n// Good - paginate\nconst locations = await prisma.location.findMany({\n  take: 50,  // Limit\n  skip: page * 50,  // Offset\n  orderBy: { createdAt: 'desc' }\n});\n

Use aggregations efficiently:

// Bad - count all then filter\nconst allUsers = await prisma.user.findMany();\nconst activeCount = allUsers.filter(u => u.role !== 'TEMP').length;\n\n// Good - count in database\nconst activeCount = await prisma.user.count({\n  where: {\n    role: { not: 'TEMP' }\n  }\n});\n
"},{"location":"v2/troubleshooting/performance-optimization/#connection-pooling","title":"Connection Pooling","text":"

Configure pool size:

# In .env\nDATABASE_URL=\"postgresql://changemaker:password@v2-postgres:5432/changemaker_v2?connection_limit=20&pool_timeout=30\"\n\n# connection_limit: Max connections (default: 10)\n# pool_timeout: Max wait time in seconds (default: 10)\n

Recommended pool sizes:

Formula:

Total connections = (API instances \u00d7 pool size) + overhead\nOverhead = Prisma Studio (1) + other clients (5)\n\nExample:\n3 instances \u00d7 10 pool + 6 overhead = 36 connections\nSet PostgreSQL max_connections = 50 (1.4\u00d7 usage)\n

Monitor pool usage:

-- View active connections\nSELECT count(*), state\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2'\nGROUP BY state;\n\n-- Alert if nearing limit\nSELECT count(*) FROM pg_stat_activity WHERE datname = 'changemaker_v2';\n-- If > 80% of max_connections, increase limit or reduce pool size\n
"},{"location":"v2/troubleshooting/performance-optimization/#read-replicas","title":"Read Replicas","text":"

For read-heavy workloads, add read replicas:

# docker-compose.yml\nv2-postgres-read:\n  image: postgres:16-alpine\n  environment:\n    POSTGRES_DB: changemaker_v2\n    POSTGRES_USER: changemaker\n    POSTGRES_PASSWORD: ${V2_POSTGRES_PASSWORD}\n  command: postgres -c wal_level=replica -c max_wal_senders=3\n

Configure replication in Prisma:

// Use read replica for read queries\nconst readPrisma = new PrismaClient({\n  datasources: {\n    db: { url: process.env.READ_DATABASE_URL }\n  }\n});\n\n// Read from replica\nconst users = await readPrisma.user.findMany();\n\n// Write to primary\nconst user = await prisma.user.create({ data: { ... } });\n
"},{"location":"v2/troubleshooting/performance-optimization/#api-optimization","title":"API Optimization","text":""},{"location":"v2/troubleshooting/performance-optimization/#caching-strategies","title":"Caching Strategies","text":"

Redis caching:

// Cache expensive operations\nimport { redis } from './config/redis';\n\nexport const getCampaigns = async () => {\n  // Check cache\n  const cacheKey = 'campaigns:all';\n  const cached = await redis.get(cacheKey);\n\n  if (cached) {\n    return JSON.parse(cached);\n  }\n\n  // Query database\n  const campaigns = await prisma.campaign.findMany({\n    include: { emails: true }\n  });\n\n  // Cache for 5 minutes\n  await redis.setex(cacheKey, 300, JSON.stringify(campaigns));\n\n  return campaigns;\n};\n

Invalidate cache on updates:

export const updateCampaign = async (id: string, data: any) => {\n  // Update database\n  const campaign = await prisma.campaign.update({\n    where: { id },\n    data\n  });\n\n  // Invalidate cache\n  await redis.del('campaigns:all');\n  await redis.del(`campaign:${id}`);\n\n  return campaign;\n};\n

Cache patterns:

"},{"location":"v2/troubleshooting/performance-optimization/#rate-limiting","title":"Rate Limiting","text":"

Configure rate limits:

// In api/src/middleware/rate-limit.ts\nimport rateLimit from 'express-rate-limit';\n\n// General API\nexport const apiRateLimit = rateLimit({\n  windowMs: 60 * 1000,  // 1 minute\n  max: 100,  // 100 requests per minute\n  standardHeaders: true,\n  legacyHeaders: false,\n});\n\n// Auth endpoints (stricter)\nexport const authRateLimit = rateLimit({\n  windowMs: 60 * 1000,\n  max: 10,  // 10 requests per minute\n  message: 'Too many login attempts. Please try again later.'\n});\n\n// Public endpoints (more lenient)\nexport const publicRateLimit = rateLimit({\n  windowMs: 60 * 1000,\n  max: 200  // 200 requests per minute\n});\n

Apply to routes:

// In server.ts\napp.use('/api/auth', authRateLimit);\napp.use('/api', apiRateLimit);\napp.use('/public', publicRateLimit);\n
"},{"location":"v2/troubleshooting/performance-optimization/#pagination","title":"Pagination","text":"

Implement cursor-based pagination:

// api/src/modules/users/users.controller.ts\nexport const getUsers = async (req: Request, res: Response) => {\n  const { cursor, limit = 50 } = req.query;\n\n  const users = await prisma.user.findMany({\n    take: Number(limit) + 1,  // Fetch one extra to check if more\n    skip: cursor ? 1 : 0,\n    cursor: cursor ? { id: cursor as string } : undefined,\n    orderBy: { createdAt: 'desc' }\n  });\n\n  const hasMore = users.length > Number(limit);\n  if (hasMore) users.pop();  // Remove extra\n\n  res.json({\n    data: users,\n    cursor: hasMore ? users[users.length - 1].id : null,\n    hasMore\n  });\n};\n

Frontend pagination:

// admin/src/pages/UsersPage.tsx\nconst [users, setUsers] = useState([]);\nconst [cursor, setCursor] = useState<string | null>(null);\nconst [hasMore, setHasMore] = useState(true);\n\nconst loadMore = async () => {\n  const response = await api.get('/api/users', {\n    params: { cursor, limit: 50 }\n  });\n\n  setUsers([...users, ...response.data.data]);\n  setCursor(response.data.cursor);\n  setHasMore(response.data.hasMore);\n};\n
"},{"location":"v2/troubleshooting/performance-optimization/#response-compression","title":"Response Compression","text":"

Enable gzip compression:

// In server.ts\nimport compression from 'compression';\n\napp.use(compression({\n  level: 6,  // Compression level (0-9)\n  threshold: 1024  // Only compress responses > 1KB\n}));\n
"},{"location":"v2/troubleshooting/performance-optimization/#frontend-optimization","title":"Frontend Optimization","text":""},{"location":"v2/troubleshooting/performance-optimization/#code-splitting","title":"Code Splitting","text":"

Route-based splitting:

// admin/src/App.tsx\nimport { lazy, Suspense } from 'react';\n\n// Lazy load pages\nconst UsersPage = lazy(() => import('./pages/UsersPage'));\nconst CampaignsPage = lazy(() => import('./pages/CampaignsPage'));\nconst LocationsPage = lazy(() => import('./pages/LocationsPage'));\n\nfunction App() {\n  return (\n    <Suspense fallback={<Spin />}>\n      <Routes>\n        <Route path=\"/app/users\" element={<UsersPage />} />\n        <Route path=\"/app/campaigns\" element={<CampaignsPage />} />\n        <Route path=\"/app/locations\" element={<LocationsPage />} />\n      </Routes>\n    </Suspense>\n  );\n}\n

Component splitting:

// Lazy load heavy components\nconst MapView = lazy(() => import('./components/MapView'));\n\nfunction Page() {\n  return (\n    <Suspense fallback={<Spin />}>\n      <MapView />\n    </Suspense>\n  );\n}\n
"},{"location":"v2/troubleshooting/performance-optimization/#lazy-loading","title":"Lazy Loading","text":"

Images:

<img\n  src={imageUrl}\n  loading=\"lazy\"  // Native lazy loading\n  alt=\"Description\"\n/>\n

Large libraries:

// Don't import large libs at top level\nimport dayjs from 'dayjs';  // \u274c Always loads\n\n// Import only when needed\nconst formatDate = async (date: Date) => {\n  const dayjs = (await import('dayjs')).default;  // \u2705 Loads on demand\n  return dayjs(date).format('YYYY-MM-DD');\n};\n
"},{"location":"v2/troubleshooting/performance-optimization/#bundle-optimization","title":"Bundle Optimization","text":"

Analyze bundle size:

cd admin\nnpm run build\nnpx vite-bundle-visualizer\n

Tree shaking:

// Import only what you need\nimport { Button } from 'antd';  // \u274c Imports all of antd\n\nimport Button from 'antd/es/button';  // \u2705 Only button\n

Configure Vite:

// admin/vite.config.ts\nexport default defineConfig({\n  build: {\n    rollupOptions: {\n      output: {\n        manualChunks: {\n          vendor: ['react', 'react-dom', 'react-router-dom'],\n          antd: ['antd'],\n          maps: ['leaflet', 'react-leaflet']\n        }\n      }\n    },\n    chunkSizeWarningLimit: 1000\n  }\n});\n
"},{"location":"v2/troubleshooting/performance-optimization/#memoization","title":"Memoization","text":"

React.memo for expensive components:

import { memo } from 'react';\n\nconst LocationMarker = memo(({ location }) => {\n  return (\n    <CircleMarker\n      center={[location.latitude, location.longitude]}\n      radius={8}\n    />\n  );\n}, (prev, next) => {\n  // Only re-render if location changed\n  return prev.location.id === next.location.id;\n});\n

useMemo for expensive calculations:

import { useMemo } from 'react';\n\nfunction MapView({ locations }) {\n  // Only recalculate when locations change\n  const bounds = useMemo(() => {\n    if (!locations.length) return null;\n    const coords = locations.map(l => [l.latitude, l.longitude]);\n    return L.latLngBounds(coords);\n  }, [locations]);\n\n  return <MapContainer bounds={bounds} />;\n}\n

useCallback for stable functions:

import { useCallback } from 'react';\n\nfunction Table({ data }) {\n  // Stable reference for row click handler\n  const handleRowClick = useCallback((row) => {\n    console.log('Clicked:', row.id);\n  }, []);\n\n  return <Table data={data} onRowClick={handleRowClick} />;\n}\n
"},{"location":"v2/troubleshooting/performance-optimization/#docker-optimization","title":"Docker Optimization","text":""},{"location":"v2/troubleshooting/performance-optimization/#resource-limits","title":"Resource Limits","text":"
# docker-compose.yml\napi:\n  deploy:\n    resources:\n      limits:\n        cpus: '2.0'  # Max 2 CPU cores\n        memory: 4G   # Max 4GB RAM\n      reservations:\n        cpus: '0.5'  # Reserve 0.5 cores\n        memory: 1G   # Reserve 1GB\n

Monitor resource usage:

docker stats\n\n# Shows:\n# CONTAINER   CPU %   MEM USAGE / LIMIT   MEM %\n# api         15%     1.2GB / 4GB         30%\n
"},{"location":"v2/troubleshooting/performance-optimization/#multi-stage-builds","title":"Multi-Stage Builds","text":"

Optimize Dockerfile:

# Build stage\nFROM node:20-alpine AS builder\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --only=production\nCOPY . .\nRUN npm run build\n\n# Runtime stage (smaller)\nFROM node:20-alpine\nWORKDIR /app\nCOPY --from=builder /app/dist ./dist\nCOPY --from=builder /app/node_modules ./node_modules\nCOPY package*.json ./\n\nCMD [\"node\", \"dist/server.js\"]\n

Benefits:

"},{"location":"v2/troubleshooting/performance-optimization/#volume-performance","title":"Volume Performance","text":"

Use cached volumes for dependencies:

api:\n  volumes:\n    - ./api:/app\n    - /app/node_modules  # Don't bind-mount node_modules\n    - api-build:/app/dist:cached  # Cache build output\n

For macOS/Windows:

api:\n  volumes:\n    - ./api:/app:cached  # Cached mode for better performance\n
"},{"location":"v2/troubleshooting/performance-optimization/#nginx-optimization","title":"Nginx Optimization","text":""},{"location":"v2/troubleshooting/performance-optimization/#gzip-compression","title":"Gzip Compression","text":"
# nginx/nginx.conf\nhttp {\n  gzip on;\n  gzip_vary on;\n  gzip_min_length 1024;\n  gzip_comp_level 6;\n  gzip_types\n    text/plain\n    text/css\n    text/xml\n    text/javascript\n    application/json\n    application/javascript\n    application/xml+rss\n    application/atom+xml\n    image/svg+xml;\n}\n
"},{"location":"v2/troubleshooting/performance-optimization/#caching","title":"Caching","text":"

Static assets:

# nginx/conf.d/default.conf\nlocation ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {\n  expires 1y;\n  add_header Cache-Control \"public, immutable\";\n}\n

API responses:

location /api/ {\n  proxy_cache api_cache;\n  proxy_cache_valid 200 5m;  # Cache 200 responses for 5 minutes\n  proxy_cache_bypass $http_cache_control;  # Honor Cache-Control header\n  add_header X-Cache-Status $upstream_cache_status;\n\n  proxy_pass http://api:4000;\n}\n
"},{"location":"v2/troubleshooting/performance-optimization/#keep-alive","title":"Keep-Alive","text":"
# nginx/nginx.conf\nhttp {\n  keepalive_timeout 65;\n  keepalive_requests 100;\n\n  upstream api {\n    server api:4000;\n    keepalive 32;  # Keep 32 connections alive to backend\n  }\n}\n
"},{"location":"v2/troubleshooting/performance-optimization/#email-queue-optimization","title":"Email Queue Optimization","text":""},{"location":"v2/troubleshooting/performance-optimization/#worker-concurrency","title":"Worker Concurrency","text":"

Increase parallel processing:

// api/src/services/email-queue.service.ts\nconst worker = new Worker('email-queue', emailProcessor, {\n  connection: redis,\n  concurrency: 5,  // Process 5 emails simultaneously\n  limiter: {\n    max: 50,  // Max 50 jobs per second\n    duration: 1000\n  }\n});\n

Recommended concurrency:

"},{"location":"v2/troubleshooting/performance-optimization/#batch-processing","title":"Batch Processing","text":"

Process emails in batches:

export const sendBulkEmails = async (emails: Email[]) => {\n  const batchSize = 100;\n\n  for (let i = 0; i < emails.length; i += batchSize) {\n    const batch = emails.slice(i, i + batchSize);\n\n    // Add batch to queue\n    await emailQueue.addBulk(\n      batch.map(email => ({\n        name: 'send-email',\n        data: email\n      }))\n    );\n  }\n};\n
"},{"location":"v2/troubleshooting/performance-optimization/#rate-limiting_1","title":"Rate Limiting","text":"

Respect SMTP provider limits:

const worker = new Worker('email-queue', emailProcessor, {\n  limiter: {\n    // Gmail: 500 emails/day (free), 2000/day (workspace)\n    max: 100,  // 100 emails per hour\n    duration: 3600 * 1000  // 1 hour\n  }\n});\n
"},{"location":"v2/troubleshooting/performance-optimization/#monitoring-performance","title":"Monitoring Performance","text":""},{"location":"v2/troubleshooting/performance-optimization/#prometheus-metrics","title":"Prometheus Metrics","text":"

Track response times:

import { Histogram } from 'prom-client';\n\nconst httpRequestDuration = new Histogram({\n  name: 'http_request_duration_seconds',\n  help: 'HTTP request duration in seconds',\n  labelNames: ['method', 'route', 'status'],\n  buckets: [0.01, 0.05, 0.1, 0.5, 1, 5]\n});\n\n// Middleware to track duration\napp.use((req, res, next) => {\n  const start = Date.now();\n\n  res.on('finish', () => {\n    const duration = (Date.now() - start) / 1000;\n    httpRequestDuration\n      .labels(req.method, req.route?.path || req.path, res.statusCode.toString())\n      .observe(duration);\n  });\n\n  next();\n});\n

Track query counts:

const dbQueries = new Counter({\n  name: 'cm_database_queries_total',\n  help: 'Total database queries',\n  labelNames: ['model', 'operation']\n});\n\n// In Prisma middleware\nprisma.$use(async (params, next) => {\n  dbQueries.labels(params.model, params.action).inc();\n  return next(params);\n});\n
"},{"location":"v2/troubleshooting/performance-optimization/#grafana-dashboards","title":"Grafana Dashboards","text":"

Create performance dashboard:

# API response time (p95)\nhistogram_quantile(0.95,\n  rate(http_request_duration_seconds_bucket[5m])\n)\n\n# Database query rate\nrate(cm_database_queries_total[5m])\n\n# Cache hit rate\nrate(cm_cache_hits_total[5m]) /\n(rate(cm_cache_hits_total[5m]) + rate(cm_cache_misses_total[5m]))\n
"},{"location":"v2/troubleshooting/performance-optimization/#slow-query-log","title":"Slow Query Log","text":"

Enable in PostgreSQL:

# docker-compose.yml\nv2-postgres:\n  command: postgres -c log_min_duration_statement=100\n  # Logs queries taking > 100ms\n

View slow queries:

docker compose logs v2-postgres | grep \"duration:\"\n\n# Output:\n# LOG:  duration: 523.456 ms  statement: SELECT * FROM \"Location\" WHERE ...\n
"},{"location":"v2/troubleshooting/performance-optimization/#load-testing","title":"Load Testing","text":""},{"location":"v2/troubleshooting/performance-optimization/#k6-load-testing","title":"k6 Load Testing","text":"

Install k6:

# macOS\nbrew install k6\n\n# Linux\nsudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69\necho \"deb https://dl.k6.io/deb stable main\" | sudo tee /etc/apt/sources.list.d/k6.list\nsudo apt-get update\nsudo apt-get install k6\n

Create test script:

// load-test.js\nimport http from 'k6/http';\nimport { check, sleep } from 'k6';\n\nexport const options = {\n  stages: [\n    { duration: '2m', target: 100 },  // Ramp up to 100 users\n    { duration: '5m', target: 100 },  // Stay at 100 users\n    { duration: '2m', target: 0 },    // Ramp down\n  ],\n  thresholds: {\n    http_req_duration: ['p(95)<500'],  // 95% of requests < 500ms\n  },\n};\n\nexport default function () {\n  // Test login\n  const loginRes = http.post('http://localhost:4000/api/auth/login', {\n    email: 'admin@example.com',\n    password: 'Admin123!',\n  });\n  check(loginRes, { 'login succeeded': (r) => r.status === 200 });\n\n  const token = loginRes.json('accessToken');\n\n  // Test API endpoints\n  const headers = { Authorization: `Bearer ${token}` };\n\n  const campaignsRes = http.get('http://localhost:4000/api/campaigns', { headers });\n  check(campaignsRes, { 'campaigns loaded': (r) => r.status === 200 });\n\n  const locationsRes = http.get('http://localhost:4000/api/map/locations', { headers });\n  check(locationsRes, { 'locations loaded': (r) => r.status === 200 });\n\n  sleep(1);\n}\n

Run test:

k6 run load-test.js\n

Interpret results:

     \u2713 login succeeded\n     \u2713 campaigns loaded\n     \u2713 locations loaded\n\n     checks.........................: 100.00%\n     data_received..................: 8.2 MB\n     data_sent......................: 1.1 MB\n     http_req_duration..............: avg=145ms  min=12ms  med=89ms  max=2.1s  p(95)=423ms\n     http_reqs......................: 12450\n     vus............................: 100\n     vus_max........................: 100\n
"},{"location":"v2/troubleshooting/performance-optimization/#apache-bench","title":"Apache Bench","text":"

Quick load test:

# 1000 requests, 10 concurrent\nab -n 1000 -c 10 http://localhost:4000/api/health\n\n# With authentication\nab -n 1000 -c 10 -H \"Authorization: Bearer TOKEN\" http://localhost:4000/api/campaigns\n
"},{"location":"v2/troubleshooting/performance-optimization/#performance-checklist","title":"Performance Checklist","text":""},{"location":"v2/troubleshooting/performance-optimization/#database","title":"Database","text":""},{"location":"v2/troubleshooting/performance-optimization/#api","title":"API","text":""},{"location":"v2/troubleshooting/performance-optimization/#frontend","title":"Frontend","text":""},{"location":"v2/troubleshooting/performance-optimization/#docker","title":"Docker","text":""},{"location":"v2/troubleshooting/performance-optimization/#nginx","title":"Nginx","text":""},{"location":"v2/troubleshooting/performance-optimization/#email-queue","title":"Email Queue","text":""},{"location":"v2/troubleshooting/performance-optimization/#monitoring","title":"Monitoring","text":""},{"location":"v2/troubleshooting/performance-optimization/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/performance-optimization/#performance-documentation","title":"Performance Documentation","text":""},{"location":"v2/troubleshooting/performance-optimization/#other-guides","title":"Other Guides","text":""},{"location":"v2/troubleshooting/performance-optimization/#external-resources","title":"External Resources","text":"

Last Updated: February 2026 Version: V2.0 Status: Complete

"},{"location":"v2/user-guides/","title":"User Guides","text":"

This section provides step-by-step guides for different user roles and common tasks. Each guide is tailored to specific workflows and responsibilities.

"},{"location":"v2/user-guides/#role-based-guides","title":"Role-Based Guides","text":""},{"location":"v2/user-guides/#admin-guide","title":"Admin Guide","text":"

For system administrators and site managers:

Target Audience: SUPER_ADMIN role

"},{"location":"v2/user-guides/#campaign-manager-guide","title":"Campaign Manager Guide","text":"

For advocacy campaign coordinators:

Target Audience: INFLUENCE_ADMIN role

"},{"location":"v2/user-guides/#map-organizer-guide","title":"Map Organizer Guide","text":"

For field organizing coordinators:

Target Audience: MAP_ADMIN role

"},{"location":"v2/user-guides/#volunteer-guide","title":"Volunteer Guide","text":"

For field canvassers:

Target Audience: USER role

"},{"location":"v2/user-guides/#content-editor-guide","title":"Content Editor Guide","text":"

For content creators:

Target Audience: SUPER_ADMIN role

"},{"location":"v2/user-guides/#common-tasks","title":"Common Tasks","text":""},{"location":"v2/user-guides/#getting-started","title":"Getting Started","text":"
  1. First Login
  2. Navigate to http://your-domain.com or http://localhost:3000
  3. Login with credentials
  4. Change default password
  5. Explore dashboard

  6. User Role Redirection

  7. Admin roles \u2192 /app/dashboard
  8. User/volunteer roles \u2192 /volunteer/dashboard
"},{"location":"v2/user-guides/#campaign-workflow","title":"Campaign Workflow","text":"
  1. Create Campaign
  2. Navigate to /app/influence/campaigns
  3. Click \"New Campaign\"
  4. Fill in details
  5. Save campaign

  6. Design Email Template

  7. Set email subject
  8. Write email body
  9. Use variable placeholders
  10. Preview template

  11. Launch Campaign

  12. Set to published
  13. Share public URL
  14. Monitor responses
"},{"location":"v2/user-guides/#location-workflow","title":"Location Workflow","text":"
  1. Import Locations
  2. Prepare CSV file
  3. Navigate to /app/map/locations
  4. Click \"Import CSV\"
  5. Map columns
  6. Import data

  7. Geocode Addresses

  8. Select ungeocode locations
  9. Click \"Geocode Selected\"
  10. Monitor progress
  11. Review quality metrics

  12. Create Geographic Cuts

  13. Navigate to /app/map/cuts
  14. Click \"Draw on Map\"
  15. Draw polygon
  16. Save cut
  17. Assign locations
"},{"location":"v2/user-guides/#volunteer-canvassing-workflow","title":"Volunteer Canvassing Workflow","text":"
  1. View Assignments
  2. Login as volunteer
  3. Navigate to /volunteer/assignments
  4. View upcoming shifts

  5. Start Canvassing

  6. Click \"Start Canvass\"
  7. Grant GPS permissions
  8. Follow walking route
  9. Visit locations

  10. Record Visits

  11. Click location marker
  12. Select outcome
  13. Add notes
  14. Submit

  15. End Session

  16. Click \"End Session\"
  17. Review statistics
  18. View in activity history
"},{"location":"v2/user-guides/#task-guides","title":"Task Guides","text":""},{"location":"v2/user-guides/#import-canadian-electoral-data-nar","title":"Import Canadian Electoral Data (NAR)","text":"
  1. Prepare Data
  2. Download NAR 2025 data
  3. Place in /data directory
  4. Ensure Address + Location files present

  5. Import via Admin

  6. Navigate to /app/map/locations
  7. Click \"Import NAR\"
  8. Select province
  9. Apply filters
  10. Start import

  11. Review Import

  12. Check location count
  13. Verify geocoding
  14. Review quality dashboard
"},{"location":"v2/user-guides/#set-up-public-campaign-page","title":"Set Up Public Campaign Page","text":"
  1. Create Campaign
  2. Configure targeting (federal/provincial)
  3. Write email template
  4. Set to published

  5. Share URL

  6. Copy public URL: /campaigns/:id
  7. Share on social media
  8. Embed in website

  9. Monitor Engagement

  10. View email statistics
  11. Moderate responses
  12. Check response wall
"},{"location":"v2/user-guides/#configure-newsletter-sync","title":"Configure Newsletter Sync","text":"
  1. Enable Listmonk
  2. Set LISTMONK_SYNC_ENABLED=true
  3. Configure API credentials
  4. Restart services

  5. Initialize Sync

  6. Navigate to /app/services/listmonk
  7. Click \"Test Connection\"
  8. Click \"Sync Participants\"

  9. Manage Lists

  10. View list statistics
  11. Configure sync settings
  12. Monitor sync status
"},{"location":"v2/user-guides/#set-up-public-tunnel","title":"Set Up Public Tunnel","text":"
  1. Create Pangolin Account
  2. Sign up at pangolin.bnkserve.org
  3. Generate API key

  4. Configure Tunnel

  5. Navigate to /app/services/pangolin
  6. Enter API key
  7. Follow setup wizard
  8. Deploy Newt container

  9. Test Public Access

  10. Visit public URL
  11. Verify subdomain routing
  12. Check SSL/TLS
"},{"location":"v2/user-guides/#create-landing-page","title":"Create Landing Page","text":"
  1. Start New Page
  2. Navigate to /app/pages
  3. Click \"New Page\"
  4. Enter title and slug

  5. Design Page

  6. Click \"Edit\"
  7. Use GrapesJS editor
  8. Drag blocks
  9. Customize content
  10. Save (Ctrl+S)

  11. Publish

  12. Set to published
  13. View at /p/:slug
  14. Share URL
"},{"location":"v2/user-guides/#best-practices","title":"Best Practices","text":""},{"location":"v2/user-guides/#campaign-management","title":"Campaign Management","text":""},{"location":"v2/user-guides/#field-organizing","title":"Field Organizing","text":""},{"location":"v2/user-guides/#content-creation","title":"Content Creation","text":""},{"location":"v2/user-guides/#system-administration","title":"System Administration","text":""},{"location":"v2/user-guides/#mobile-usage","title":"Mobile Usage","text":""},{"location":"v2/user-guides/#volunteer-canvassing","title":"Volunteer Canvassing","text":"

Best on mobile devices:

"},{"location":"v2/user-guides/#admin-tasks","title":"Admin Tasks","text":"

Best on desktop:

"},{"location":"v2/user-guides/#keyboard-shortcuts","title":"Keyboard Shortcuts","text":""},{"location":"v2/user-guides/#page-editor","title":"Page Editor","text":""},{"location":"v2/user-guides/#general","title":"General","text":""},{"location":"v2/user-guides/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/user-guides/admin-guide/","title":"Administrator Guide","text":""},{"location":"v2/user-guides/admin-guide/#overview","title":"Overview","text":"

The Administrator role is the highest-level role in Changemaker Lite. As an administrator, you have complete control over the platform, including:

This guide will walk you through all administrative functions in Changemaker Lite V2.

"},{"location":"v2/user-guides/admin-guide/#getting-started","title":"Getting Started","text":""},{"location":"v2/user-guides/admin-guide/#first-login","title":"First Login","text":"

When you first access Changemaker Lite, you'll log in at the admin portal URL:

https://app.cmlite.org\n

Or if running locally:

http://localhost:3000\n

Default credentials (change immediately after first login):

Security Critical

The default password is publicly known. Change it immediately after your first login to prevent unauthorized access.

Screenshot placeholder: Login page showing email/password fields and \"Remember me\" checkbox

"},{"location":"v2/user-guides/admin-guide/#dashboard-overview","title":"Dashboard Overview","text":"

After logging in, you'll see the Administrator Dashboard, which provides an at-a-glance overview of your platform:

Key dashboard sections:

  1. Statistics Cards
  2. Total users (breakdown by role)
  3. Active campaigns
  4. Total locations
  5. Active canvass sessions

  6. Recent Activity Feed

  7. New user registrations
  8. Campaign responses
  9. Shift signups
  10. Canvass visits

  11. Quick Actions

  12. Create new campaign
  13. Import locations
  14. Create volunteer shift
  15. View email queue

  16. System Health

  17. API status
  18. Database connectivity
  19. Redis cache status
  20. Queue worker status

Screenshot placeholder: Dashboard showing statistics cards, activity feed, and quick action buttons

"},{"location":"v2/user-guides/admin-guide/#changing-your-password","title":"Changing Your Password","text":"

Required First Step

You must change the default password before performing any other administrative tasks.

To change your password:

  1. Click your email address in the top-right corner
  2. Select \"Change Password\" from the dropdown
  3. Enter your current password
  4. Enter new password (must meet requirements below)
  5. Confirm new password
  6. Click \"Update Password\"

Password requirements:

Screenshot placeholder: Change password modal showing current/new password fields and requirements checklist

"},{"location":"v2/user-guides/admin-guide/#navigating-the-admin-interface","title":"Navigating the Admin Interface","text":"

The admin interface uses a sidebar navigation with the following sections:

Main Navigation:

Screenshot placeholder: Sidebar navigation showing expanded Influence and Map sections

"},{"location":"v2/user-guides/admin-guide/#user-management","title":"User Management","text":""},{"location":"v2/user-guides/admin-guide/#creating-users","title":"Creating Users","text":"

To create a new user:

  1. Navigate to Users in the sidebar
  2. Click \"Create User\" button (top-right)
  3. Fill in user details:
  4. Email: User's email address (must be unique)
  5. Name: User's full name
  6. Password: Temporary password (user should change on first login)
  7. Role: Select from dropdown (see roles below)
  8. Status: ACTIVE or SUSPENDED
  9. Click \"Create\"

The new user will receive a welcome email (if email is configured) with their login credentials.

Screenshot placeholder: Create User modal showing email, name, password, role dropdown, and status toggle

"},{"location":"v2/user-guides/admin-guide/#understanding-roles","title":"Understanding Roles","text":"

Changemaker Lite has five user roles with different permission levels:

"},{"location":"v2/user-guides/admin-guide/#1-super_admin-you","title":"1. SUPER_ADMIN (You)","text":""},{"location":"v2/user-guides/admin-guide/#2-influence_admin","title":"2. INFLUENCE_ADMIN","text":""},{"location":"v2/user-guides/admin-guide/#3-map_admin","title":"3. MAP_ADMIN","text":""},{"location":"v2/user-guides/admin-guide/#4-user","title":"4. USER","text":""},{"location":"v2/user-guides/admin-guide/#5-temp","title":"5. TEMP","text":"

Role Upgrading

You can upgrade TEMP users to USER role to give them full volunteer access. This is common after a volunteer attends their first shift.

Screenshot placeholder: User list table showing users with different roles and color-coded role badges

"},{"location":"v2/user-guides/admin-guide/#managing-existing-users","title":"Managing Existing Users","text":"

The Users page shows all user accounts in a searchable, filterable table.

Table columns:

Available filters:

Screenshot placeholder: Users table with search bar, role filter dropdown, and action buttons

"},{"location":"v2/user-guides/admin-guide/#editing-users","title":"Editing Users","text":"

To edit a user:

  1. Click the Edit icon (pencil) in the Actions column
  2. Modify any of:
  3. Name
  4. Email (must remain unique)
  5. Role (change permissions)
  6. Status (activate/suspend)
  7. Click \"Save\"

Email Changes

Changing a user's email will require them to log in with the new email address. Notify them before making this change.

"},{"location":"v2/user-guides/admin-guide/#suspending-users","title":"Suspending Users","text":"

To temporarily disable a user account:

  1. Find the user in the table
  2. Click \"Suspend\" in the Actions column
  3. Confirm suspension

Suspended users:

When to suspend:

Screenshot placeholder: Suspend confirmation dialog explaining effects

"},{"location":"v2/user-guides/admin-guide/#password-resets","title":"Password Resets","text":"

To reset a user's password:

  1. Edit the user
  2. Click \"Reset Password\"
  3. Choose one of:
  4. Generate temporary password (shown on screen, expires in 24 hours)
  5. Send reset email (user clicks link to set new password)
  6. Provide temporary password to user securely (not via email)

Security Best Practice

Always use \"Send reset email\" option when possible. Only generate temporary passwords for in-person support scenarios.

"},{"location":"v2/user-guides/admin-guide/#deleting-users","title":"Deleting Users","text":"

Permanent Action

Deleting a user is permanent and cannot be undone. All associated data (canvass visits, responses, etc.) will be anonymized.

To delete a user:

  1. Click the Delete icon (trash) in the Actions column
  2. Type the user's email to confirm
  3. Click \"Delete Permanently\"

When deletion is appropriate:

Data handling on deletion:

"},{"location":"v2/user-guides/admin-guide/#viewing-login-activity","title":"Viewing Login Activity","text":"

To see recent login activity:

  1. Navigate to Users
  2. Check the \"Last Login\" column
  3. Click on a user to see detailed login history (if audit logging is enabled)

Screenshot placeholder: User detail view showing login history table with timestamps and IP addresses

"},{"location":"v2/user-guides/admin-guide/#campaign-management","title":"Campaign Management","text":""},{"location":"v2/user-guides/admin-guide/#campaign-overview","title":"Campaign Overview","text":"

Campaigns are at the heart of the Influence module. A campaign allows citizens to:

  1. Enter their postal code
  2. Find their elected representatives
  3. Send advocacy emails
  4. Share their story on a public response wall

As an administrator, you can create, configure, publish, and monitor campaigns.

"},{"location":"v2/user-guides/admin-guide/#creating-a-campaign","title":"Creating a Campaign","text":"

To create a new campaign:

  1. Navigate to Influence > Campaigns
  2. Click \"Create Campaign\" (top-right)
  3. Fill in the campaign form (see fields below)
  4. Click \"Create\"

Required fields:

Basic Information:

Email Configuration:

Targeting:

Screenshot placeholder: Create Campaign form showing title, slug, description, email subject, and body editor

"},{"location":"v2/user-guides/admin-guide/#understanding-feature-flags","title":"Understanding Feature Flags","text":"

Campaigns have 12 feature flags that control functionality:

"},{"location":"v2/user-guides/admin-guide/#core-features","title":"Core Features","text":"
  1. Published
  2. Controls public visibility
  3. Unpublished campaigns only visible to admins
  4. Toggle to launch/pause campaign

  5. Featured

  6. Featured campaigns appear at top of listing page
  7. Use for high-priority campaigns
  8. Limit to 2-3 featured campaigns

  9. Has Response Wall

  10. Enables public response wall
  11. Citizens can share their story after emailing
  12. Responses require admin approval (unless auto_approve_responses)

  13. Collect Phone Numbers

  14. Adds optional phone number field
  15. Used for call-in campaigns
  16. Numbers stored for admin use

  17. Track Calls

  18. Adds \"I called my representative\" button
  19. Tracks call attempts separately from emails
  20. Good for blended campaigns
"},{"location":"v2/user-guides/admin-guide/#advanced-features","title":"Advanced Features","text":"
  1. Require Verification
  2. Sends verification email before submitting
  3. Prevents spam and bot submissions
  4. Recommended for public campaigns

  5. Auto Approve Responses

  6. Response wall submissions appear immediately
  7. No admin moderation required
  8. Only use for trusted campaigns

  9. Allow Anonymous

  10. Citizens can submit without creating account
  11. Reduces friction but limits tracking
  12. Good for privacy-sensitive topics

  13. Custom Recipients

  14. Override representative lookup
  15. Send to specific email addresses
  16. Use for non-government campaigns

  17. Show Progress Bar

    • Displays email count goal and progress
    • Motivates participation
    • Requires setting email_goal field
  18. Disable After Date

    • Automatically unpublish after specified date
    • Good for time-sensitive campaigns
    • Requires setting disable_date field
  19. Enable Comments

    • Allow comments on response wall entries
    • Creates discussion threads
    • Requires moderation

Screenshot placeholder: Campaign feature flags showing toggles for all 12 flags with descriptive labels

Recommended Defaults

For most campaigns, enable: Published, Has Response Wall, Require Verification. Leave others off unless specifically needed.

"},{"location":"v2/user-guides/admin-guide/#configuring-email-template","title":"Configuring Email Template","text":"

The email template is what citizens send to their representatives. Make it:

Effective email guidelines:

Example template:

Subject: Please vote YES on Bill C-123 for climate action\n\nDear {{REP_NAME}},\n\nMy name is {{USER_NAME}}, and I am a constituent in your riding. I'm writing to urge you to vote YES on Bill C-123, the Climate Action Framework.\n\nClimate change is the defining issue of our generation. This bill provides a realistic pathway to reduce emissions while protecting jobs and supporting workers.\n\nI'm specifically asking you to:\n1. Vote YES on Bill C-123 when it comes to the floor\n2. Speak publicly in support of climate action\n3. Oppose any amendments that weaken the bill\n\nThank you for considering my views. I look forward to your response.\n\nSincerely,\n{{USER_NAME}}\n{{USER_EMAIL}}\n\n---\n{{USER_MESSAGE}}\n

Available variables:

Screenshot placeholder: Email template editor showing subject and body fields with variable insertion dropdown

"},{"location":"v2/user-guides/admin-guide/#publishing-a-campaign","title":"Publishing a Campaign","text":"

Before publishing, verify:

To publish:

  1. Edit the campaign
  2. Toggle \"Published\" flag to ON
  3. Click \"Save\"

The campaign is now live at /campaigns/[slug].

Promoting your campaign:

Screenshot placeholder: Published campaign card on public campaigns listing page

"},{"location":"v2/user-guides/admin-guide/#monitoring-email-sends","title":"Monitoring Email Sends","text":"

To view email statistics:

  1. Navigate to Influence > Campaigns
  2. Click \"Emails\" button in the Actions column for your campaign

The Campaign Emails drawer shows:

Statistics:

Email list table:

Actions:

Screenshot placeholder: Campaign Emails drawer showing statistics cards and email list table

"},{"location":"v2/user-guides/admin-guide/#managing-the-email-queue","title":"Managing the Email Queue","text":"

The email queue processes advocacy emails asynchronously using BullMQ.

To monitor queue health:

  1. Navigate to Influence > Email Queue

Queue statistics:

Queue controls:

Queue Pausing

Only pause the queue during system maintenance or if email configuration is broken. Citizens expect immediate sends.

Screenshot placeholder: Email Queue page showing statistics cards, job counts, and control buttons

"},{"location":"v2/user-guides/admin-guide/#moderating-responses","title":"Moderating Responses","text":"

If your campaign has \"Has Response Wall\" enabled, citizens can share their stories publicly.

To moderate responses:

  1. Navigate to Influence > Responses
  2. Use filters to find pending responses
  3. Review each response
  4. Approve or reject

Response filters:

Response table columns:

Screenshot placeholder: Responses table with filter controls and status badges

To review a response:

  1. Click \"View\" in Actions column
  2. Read full response text
  3. Decide:
  4. Approve: Make public (appears on response wall)
  5. Reject: Hide from public (not deleted)
  6. Delete: Permanently remove

Moderation guidelines:

Approve responses that:

Reject responses that:

Screenshot placeholder: Response detail modal showing full text, citizen info, and approve/reject buttons

"},{"location":"v2/user-guides/admin-guide/#location-management","title":"Location Management","text":""},{"location":"v2/user-guides/admin-guide/#location-data-overview","title":"Location Data Overview","text":"

Locations represent physical addresses where canvassing occurs. Each location has:

"},{"location":"v2/user-guides/admin-guide/#importing-locations-from-csv","title":"Importing Locations from CSV","text":"

To import locations:

  1. Navigate to Map > Locations
  2. Click \"Import CSV\" button
  3. Upload CSV file
  4. Map CSV columns to location fields
  5. Click \"Import\"

Required CSV columns:

Optional columns:

CSV example:

address,city,province,postalCode,buildingType\n\"123 Main St\",\"Ottawa\",\"ON\",\"K1A 0B1\",\"RESIDENTIAL\"\n\"456 Queen St E\",\"Toronto\",\"ON\",\"M5A 1T1\",\"APARTMENT\"\n\"789 Granville St\",\"Vancouver\",\"BC\",\"V6Z 1K3\",\"BUSINESS\"\n

Excel to CSV

If your data is in Excel, use \"Save As\" > \"CSV (Comma delimited)\" to export.

Screenshot placeholder: CSV import dialog showing file upload, column mapping interface, and preview table

"},{"location":"v2/user-guides/admin-guide/#nar-import-canadian-electoral-data","title":"NAR Import (Canadian Electoral Data)","text":"

For Canadian campaigns, you can import official electoral data from Elections Canada NAR (National Address Register) files.

To import NAR data:

  1. Navigate to Map > Locations
  2. Click \"NAR Import\" button
  3. Select province
  4. Choose NAR dataset (year)
  5. Apply filters:
  6. City filter (optional)
  7. Postal code filter (optional)
  8. Cut filter (assign to specific cut)
  9. Residential only (exclude commercial)
  10. Click \"Start Import\"

The import runs server-side and can take several minutes for large provinces.

NAR data includes:

Screenshot placeholder: NAR Import modal showing province selector, dataset picker, and filter options

NAR Data Source

NAR data must be obtained from Elections Canada and placed in the /data directory on the server. Contact your system administrator.

"},{"location":"v2/user-guides/admin-guide/#geocoding-addresses","title":"Geocoding Addresses","text":"

Geocoding converts addresses to latitude/longitude coordinates for map display.

Automatic geocoding:

Manual geocoding:

  1. Navigate to Map > Locations
  2. Filter for \"Ungeocoded\" locations
  3. Select locations to geocode
  4. Click \"Geocode Selected\" (bulk action)

Geocoding providers (tried in order):

  1. Nominatim (OpenStreetMap) \u2014 Free, no API key required
  2. ArcGIS \u2014 Free tier, accurate for North America
  3. Photon \u2014 Free, Europe-focused
  4. Mapbox \u2014 Requires API key, very accurate
  5. Google \u2014 Requires API key, most accurate
  6. LocationIQ \u2014 Requires API key, Nominatim-based

Geocoding Quality

Check Map > Data Quality to review geocoding confidence levels. Re-geocode low-confidence addresses.

Screenshot placeholder: Locations table with \"Geocode Selected\" button and geocoding status column

"},{"location":"v2/user-guides/admin-guide/#creating-cuts","title":"Creating Cuts","text":"

Cuts are geographic areas (wards, neighborhoods, districts) used to organize canvassing.

To create a cut:

  1. Navigate to Map > Cuts
  2. Click the \"Map Drawing\" tab
  3. Click \"Start Drawing\"
  4. Click on the map to add polygon vertices
  5. Close the polygon (click near first point)
  6. Fill in cut details:
  7. Name: Cut identifier (e.g., \"Ward 5\", \"Downtown\")
  8. Category: WARD, NEIGHBORHOOD, DISTRICT, or CUSTOM
  9. Color: Display color on map
  10. Description: Internal notes
  11. Click \"Save Cut\"

Cut best practices:

Screenshot placeholder: Cut drawing map interface showing polygon being drawn with vertex markers

"},{"location":"v2/user-guides/admin-guide/#assigning-locations-to-cuts","title":"Assigning Locations to Cuts","text":"

Automatic assignment (during cut creation):

Manual assignment:

  1. Navigate to Map > Locations
  2. Select locations to assign
  3. Choose \"Assign to Cut\" from bulk actions
  4. Select target cut
  5. Click \"Assign\"

Viewing cut assignments:

Screenshot placeholder: Bulk action modal showing \"Assign to Cut\" with cut selector dropdown

"},{"location":"v2/user-guides/admin-guide/#managing-locations","title":"Managing Locations","text":"

To edit a location:

  1. Navigate to Map > Locations
  2. Click \"Edit\" in Actions column
  3. Modify fields:
  4. Address details
  5. Coordinates (manually adjust map pin)
  6. Building type
  7. Unit count
  8. Notes
  9. Cut assignment
  10. Click \"Save\"

To delete locations:

  1. Select locations in table
  2. Choose \"Delete\" from bulk actions
  3. Confirm deletion

Canvass History

Deleting a location preserves associated canvass visits (visits are linked to coordinates, not location records).

Screenshot placeholder: Edit Location modal showing address fields, map with draggable pin, and metadata fields

"},{"location":"v2/user-guides/admin-guide/#exporting-walk-sheets","title":"Exporting Walk Sheets","text":"

Walk sheets are printable lists of addresses for door-to-door canvassing.

To generate a walk sheet:

  1. Navigate to Map > Locations
  2. Filter to specific cut
  3. Click \"Walk Sheet\" in the cut's action menu

OR:

  1. Navigate to Canvass > Walk Sheet
  2. Select cut from dropdown
  3. Configure settings (see below)
  4. Click \"Print\"

Walk sheet settings (from Map > Map Settings):

Walk sheet contents:

Screenshot placeholder: Walk sheet PDF preview showing header, QR code, and address table

"},{"location":"v2/user-guides/admin-guide/#volunteer-management","title":"Volunteer Management","text":""},{"location":"v2/user-guides/admin-guide/#creating-shifts","title":"Creating Shifts","text":"

Shifts are scheduled volunteer canvassing sessions assigned to specific cuts.

To create a shift:

  1. Navigate to Map > Shifts
  2. Click \"Create Shift\"
  3. Fill in shift details:
  4. Title: Shift name (e.g., \"Saturday Morning Canvass - Ward 5\")
  5. Description: Additional details for volunteers
  6. Start Time: Shift start date and time
  7. End Time: Shift end date and time
  8. Cut: Which cut to canvass (optional, but recommended)
  9. Max Signups: Capacity limit (0 = unlimited)
  10. Meeting Location: Where volunteers should meet
  11. Click \"Create\"

Screenshot placeholder: Create Shift modal showing date/time picker, cut selector, and capacity field

Cut Assignment

Shifts assigned to a cut appear in the volunteer portal under \"My Assignments\" for volunteers who signed up. Volunteers can start canvassing directly from their dashboard.

"},{"location":"v2/user-guides/admin-guide/#managing-shift-signups","title":"Managing Shift Signups","text":"

To view shift signups:

  1. Navigate to Map > Shifts
  2. Click \"Signups\" in Actions column

The signups drawer shows:

Signup sources:

Screenshot placeholder: Shift Signups drawer showing capacity gauge and signup list table

"},{"location":"v2/user-guides/admin-guide/#emailing-shift-volunteers","title":"Emailing Shift Volunteers","text":"

To email all volunteers in a shift:

  1. Navigate to Map > Shifts
  2. Click \"Signups\" for the shift
  3. Click \"Email All\" button
  4. Compose email:
  5. Subject: Email subject line
  6. Body: Message (supports HTML)
  7. Variables: Use {{NAME}}, {{SHIFT_TITLE}}, {{SHIFT_START}}
  8. Click \"Send\"

Common email scenarios:

Screenshot placeholder: Email Volunteers modal showing subject, body editor, and variable insertion buttons

"},{"location":"v2/user-guides/admin-guide/#monitoring-canvass-sessions","title":"Monitoring Canvass Sessions","text":"

To view active canvass sessions:

  1. Navigate to Canvass > Dashboard

The dashboard shows:

Statistics cards:

Activity feed:

Cut progress table:

Leaderboard:

Screenshot placeholder: Canvass Dashboard showing stats cards, activity feed, and leaderboard

"},{"location":"v2/user-guides/admin-guide/#viewing-canvass-activity-reports","title":"Viewing Canvass Activity Reports","text":"

To see detailed canvassing data:

  1. Navigate to Canvass > Dashboard
  2. Use filters:
  3. Date range: Last 7 days, last 30 days, custom
  4. Cut: Specific cut or all
  5. Volunteer: Specific volunteer or all
  6. Outcome: Filter by visit outcome

Exportable reports:

Screenshot placeholder: Activity report filters and export buttons

"},{"location":"v2/user-guides/admin-guide/#site-configuration","title":"Site Configuration","text":""},{"location":"v2/user-guides/admin-guide/#site-settings","title":"Site Settings","text":"

To configure global site settings:

  1. Navigate to Settings (gear icon in sidebar)

Available settings:

Branding:

Email Configuration:

Representative API:

Feature Toggles:

Screenshot placeholder: Settings page showing branding, email, and feature toggle sections

Test Email Configuration

After changing SMTP settings, click \"Send Test Email\" to verify configuration before publishing campaigns.

"},{"location":"v2/user-guides/admin-guide/#map-settings","title":"Map Settings","text":"

To configure map defaults:

  1. Navigate to Map > Map Settings

Map Configuration:

Walk Sheet Configuration:

Screenshot placeholder: Map Settings page showing map center picker and walk sheet config

"},{"location":"v2/user-guides/admin-guide/#feature-toggles","title":"Feature Toggles","text":"

Feature toggles allow you to enable/disable major platform features without code changes.

To manage feature toggles:

  1. Navigate to Settings
  2. Scroll to Feature Toggles section
  3. Toggle features on/off
  4. Click \"Save\"

Available toggles:

ENABLE_MEDIA_FEATURES

ENABLE_LISTMONK_SYNC

ALLOW_PUBLIC_SHIFT_SIGNUP

REQUIRE_EMAIL_VERIFICATION

Screenshot placeholder: Feature Toggles section showing four toggles with descriptions

Media Features

Enabling media features requires the media-api Docker container to be running. Check with your system administrator.

"},{"location":"v2/user-guides/admin-guide/#email-templates","title":"Email Templates","text":""},{"location":"v2/user-guides/admin-guide/#understanding-email-templates","title":"Understanding Email Templates","text":"

Changemaker Lite uses email templates for system-generated emails:

System templates:

Custom templates:

"},{"location":"v2/user-guides/admin-guide/#editing-templates","title":"Editing Templates","text":"

To edit an email template:

  1. Navigate to Content > Email Templates
  2. Click \"Edit\" for the template
  3. Modify:
  4. Subject: Email subject line
  5. HTML Body: Rich email content
  6. Plain Text Body: Fallback for text-only clients
  7. Use variables (e.g., {{USER_NAME}}, {{SHIFT_TITLE}})
  8. Click \"Preview\" to see rendered email
  9. Click \"Save\"

Screenshot placeholder: Email Template Editor showing subject field, HTML editor, and variable buttons

"},{"location":"v2/user-guides/admin-guide/#available-variables","title":"Available Variables","text":"

Templates support variable interpolation:

User variables:

Shift variables:

Campaign variables:

System variables:

Screenshot placeholder: Variable reference table in template editor sidebar

"},{"location":"v2/user-guides/admin-guide/#testing-templates","title":"Testing Templates","text":"

To test an email template:

  1. Edit the template
  2. Click \"Send Test Email\"
  3. Enter your email address
  4. Click \"Send\"

You'll receive the email with sample data filled in for variables.

Always Test

Test templates before using them in production. Check both HTML and plain text versions.

"},{"location":"v2/user-guides/admin-guide/#media-library","title":"Media Library","text":"

Optional Feature

Media features must be enabled via Settings > Feature Toggles > ENABLE_MEDIA_FEATURES. Requires media-api service.

"},{"location":"v2/user-guides/admin-guide/#uploading-videos","title":"Uploading Videos","text":"

To upload a video:

  1. Navigate to Content > Media > Library
  2. Click \"Upload Video\"
  3. Either:
  4. Drag and drop video file
  5. Click to browse and select file
  6. Fill in metadata:
  7. Title: Video title
  8. Description: Video description
  9. Producer: Organization or creator
  10. Creator: Individual creator/director
  11. Tags: Comma-separated tags
  12. Directory: Organize into folders
  13. Click \"Upload\"

Supported formats:

File size limit: 10 GB per file

Screenshot placeholder: Upload Video modal showing drag-drop area, metadata form, and progress bar

"},{"location":"v2/user-guides/admin-guide/#automatic-metadata-extraction","title":"Automatic Metadata Extraction","text":"

When you upload a video, the system automatically extracts:

This metadata is used for filtering and organizing videos.

"},{"location":"v2/user-guides/admin-guide/#organizing-the-library","title":"Organizing the Library","text":"

Directory structure:

Filtering videos:

Sorting:

Screenshot placeholder: Media Library showing directory tree, filters, and video grid

"},{"location":"v2/user-guides/admin-guide/#sharing-videos-publicly","title":"Sharing Videos Publicly","text":"

To make videos public:

  1. Navigate to Content > Media > Shared Media
  2. Select videos from library
  3. Choose category:
  4. TESTIMONIAL
  5. EVENT
  6. EDUCATIONAL
  7. PROMOTIONAL
  8. Click \"Share\"

Shared videos appear on the public media gallery at /media.

To unshare videos:

  1. Go to Shared Media
  2. Select videos
  3. Click \"Unshare\"

Screenshot placeholder: Shared Media page showing category filter and share/unshare buttons

"},{"location":"v2/user-guides/admin-guide/#locking-videos","title":"Locking Videos","text":"

Locked videos cannot be deleted or moved. Use locks to protect important content.

To lock a video:

  1. Select video in library
  2. Click \"Lock\" (padlock icon)

To unlock:

  1. Select locked video
  2. Click \"Unlock\"

Lock Before Sharing

Lock videos before sharing publicly to prevent accidental deletion.

"},{"location":"v2/user-guides/admin-guide/#monitoring-reports","title":"Monitoring & Reports","text":""},{"location":"v2/user-guides/admin-guide/#viewing-queue-status","title":"Viewing Queue Status","text":"

To monitor the email queue:

  1. Navigate to Influence > Email Queue

Key metrics:

Queue health indicators:

Screenshot placeholder: Email Queue dashboard showing job counts with color-coded health indicators

"},{"location":"v2/user-guides/admin-guide/#geocoding-quality-dashboard","title":"Geocoding Quality Dashboard","text":"

To review geocoding quality:

  1. Navigate to Map > Data Quality

Quality metrics:

Quality breakdown:

Actions:

Screenshot placeholder: Data Quality Dashboard showing geocoding statistics and confidence distribution chart

"},{"location":"v2/user-guides/admin-guide/#canvass-completion-statistics","title":"Canvass Completion Statistics","text":"

To view canvass progress:

  1. Navigate to Canvass > Dashboard

Completion metrics:

Support level analysis:

Volunteer performance:

Screenshot placeholder: Canvass statistics showing completion gauges, outcome pie chart, and support level breakdown

"},{"location":"v2/user-guides/admin-guide/#observability-dashboard","title":"Observability Dashboard","text":"

To monitor system health:

  1. Navigate to Observability

The observability dashboard has three tabs:

"},{"location":"v2/user-guides/admin-guide/#metrics-tab","title":"Metrics Tab","text":"

Screenshot placeholder: Metrics tab showing API uptime gauge and request count graph

"},{"location":"v2/user-guides/admin-guide/#dashboards-tab","title":"Dashboards Tab","text":"

Screenshot placeholder: Dashboards tab showing three dashboard cards with \"Open\" buttons

"},{"location":"v2/user-guides/admin-guide/#alerts-tab","title":"Alerts Tab","text":"

Common alerts:

Screenshot placeholder: Alerts tab showing active alert for \"Queue Backed Up\" with severity and details

"},{"location":"v2/user-guides/admin-guide/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/user-guides/admin-guide/#common-admin-issues","title":"Common Admin Issues","text":""},{"location":"v2/user-guides/admin-guide/#issue-cannot-log-in","title":"Issue: Cannot Log In","text":"

Symptoms: \"Invalid credentials\" error

Solutions:

  1. Verify email address: Check for typos, spaces
  2. Try password reset: Use \"Forgot Password\" link
  3. Check account status: Ask another admin if account is suspended
  4. Check browser console: Look for API errors
"},{"location":"v2/user-guides/admin-guide/#issue-emails-not-sending","title":"Issue: Emails Not Sending","text":"

Symptoms: Emails stuck in \"Waiting\" status

Solutions:

  1. Check SMTP configuration:
  2. Navigate to Settings
  3. Verify SMTP host, port, username, password
  4. Click \"Send Test Email\"
  5. Check email queue:
  6. Navigate to Influence > Email Queue
  7. Look for error messages in failed jobs
  8. Check email test mode:
  9. If EMAIL_TEST_MODE=true, emails go to MailHog (not real recipients)
  10. Change in environment settings
  11. Restart queue worker:
  12. Ask system administrator to restart api service
"},{"location":"v2/user-guides/admin-guide/#issue-csv-import-fails","title":"Issue: CSV Import Fails","text":"

Symptoms: Error during CSV upload

Solutions:

  1. Check CSV format:
  2. Must be valid CSV (comma-separated)
  3. First row must be headers
  4. Required columns: address, city, province, postalCode
  5. Check file encoding:
  6. Use UTF-8 encoding
  7. Excel users: \"Save As\" > \"CSV UTF-8\"
  8. Check file size:
  9. Maximum 10,000 rows per import
  10. Split large files
  11. Check for special characters:
  12. Remove emoji, unusual symbols
  13. Use standard quotes (\"not \"\" or '')
"},{"location":"v2/user-guides/admin-guide/#issue-geocoding-fails","title":"Issue: Geocoding Fails","text":"

Symptoms: Addresses remain ungeocoded after import

Solutions:

  1. Check address format:
  2. Include full civic address
  3. Include city and postal code
  4. Use standard abbreviations (St, Ave, Rd)
  5. Check geocoding providers:
  6. Navigate to Map > Data Quality
  7. See which providers are responding
  8. Try manual geocoding:
  9. Edit location
  10. Click and drag map pin to correct position
  11. Save
  12. Use NAR data (Canada only):
  13. NAR import includes pre-geocoded coordinates
  14. More reliable than automatic geocoding
"},{"location":"v2/user-guides/admin-guide/#issue-map-not-loading","title":"Issue: Map Not Loading","text":"

Symptoms: Blank map or loading spinner

Solutions:

  1. Check browser console: Look for JavaScript errors
  2. Check internet connection: Map tiles require network
  3. Try different browser: Test in Chrome, Firefox
  4. Clear browser cache: Hard refresh (Ctrl+Shift+R)
  5. Check locations:
  6. Navigate to Map > Locations
  7. Verify locations have coordinates
  8. At least one location needed to display map
"},{"location":"v2/user-guides/admin-guide/#issue-campaign-not-appearing-publicly","title":"Issue: Campaign Not Appearing Publicly","text":"

Symptoms: Campaign visible in admin but not on /campaigns

Solutions:

  1. Check \"Published\" flag:
  2. Edit campaign
  3. Ensure \"Published\" toggle is ON
  4. Save
  5. Check URL:
  6. Campaign URL is /campaigns/[slug]
  7. Slug is auto-generated from title
  8. Must be unique
  9. Clear browser cache: Public pages may be cached
  10. Check representative lookup:
  11. Test with your postal code
  12. If lookup fails, campaign won't display form
"},{"location":"v2/user-guides/admin-guide/#issue-volunteer-cannot-start-canvass-session","title":"Issue: Volunteer Cannot Start Canvass Session","text":"

Symptoms: Error when volunteer clicks \"Start Canvassing\"

Solutions:

  1. Check shift assignment:
  2. Navigate to Map > Shifts
  3. Verify shift has a cut assigned
  4. Shifts without cuts cannot be canvassed
  5. Check volunteer role:
  6. Navigate to Users
  7. Verify volunteer is USER role (not TEMP)
  8. Upgrade TEMP users to USER
  9. Check cut locations:
  10. Navigate to Map > Cuts
  11. Verify cut has locations assigned
  12. Empty cuts cannot be canvassed
  13. Check for existing session:
  14. Volunteer may have abandoned session
  15. Ask admin to close abandoned session
"},{"location":"v2/user-guides/admin-guide/#getting-help","title":"Getting Help","text":"

Documentation:

Support channels:

Before asking for help:

  1. Check browser console for errors (F12)
  2. Try in different browser
  3. Check server logs (if you have access)
  4. Document steps to reproduce issue
"},{"location":"v2/user-guides/admin-guide/#related-documentation","title":"Related Documentation","text":"

Last updated: February 2026 (V2 complete)

"},{"location":"v2/user-guides/campaign-manager-guide/","title":"Campaign Manager Guide","text":""},{"location":"v2/user-guides/campaign-manager-guide/#overview","title":"Overview","text":"

As a Campaign Manager, you're responsible for planning, launching, and optimizing advocacy campaigns using Changemaker Lite's Influence module. This guide will help you:

Whether you're running a small local campaign or a national advocacy push, this guide provides strategies and best practices for success.

"},{"location":"v2/user-guides/campaign-manager-guide/#understanding-campaign-roles","title":"Understanding Campaign Roles","text":"

You may have one of two roles that allow campaign management:

"},{"location":"v2/user-guides/campaign-manager-guide/#super_admin","title":"SUPER_ADMIN","text":""},{"location":"v2/user-guides/campaign-manager-guide/#influence_admin","title":"INFLUENCE_ADMIN","text":"

Role Specialization

If you only manage campaigns (not volunteers or locations), ask for INFLUENCE_ADMIN role. This keeps the interface focused on your work.

"},{"location":"v2/user-guides/campaign-manager-guide/#planning-a-campaign","title":"Planning a Campaign","text":""},{"location":"v2/user-guides/campaign-manager-guide/#defining-campaign-goals","title":"Defining Campaign Goals","text":"

Before creating a campaign in the system, clarify your objectives:

Advocacy goals:

  1. Awareness: Educate the public about an issue
  2. Pressure: Generate constituent contact to influence decision-makers
  3. Mobilization: Build a list of supporters for future action
  4. Visibility: Demonstrate public support through response wall

Measurable targets:

Example campaign plan:

Campaign: Stop Bill 123 - Protect Clean Water\nGoal: Generate 5,000 emails to provincial MPPs before second reading vote\nTarget audience: Ontario residents (all 124 ridings)\nTimeline: 3 weeks (Feb 1-22)\nSuccess metrics:\n- 5,000+ emails sent\n- 500+ response wall submissions\n- 10% conversion rate (visitors \u2192 emails sent)\n- 50% email delivery success rate\n
"},{"location":"v2/user-guides/campaign-manager-guide/#understanding-your-target-audience","title":"Understanding Your Target Audience","text":"

Who are you trying to reach?

By government level:

By geography:

By demographics (requires custom targeting):

Representative Lookup

Changemaker Lite uses postal codes to look up representatives via the Represent API. Ensure your target government level has postal code coverage.

"},{"location":"v2/user-guides/campaign-manager-guide/#crafting-your-message","title":"Crafting Your Message","text":"

Your campaign email is the core of your advocacy effort. It should be:

1. Personal

2. Clear and Specific

3. Compelling

4. Actionable

5. Respectful

Example effective email:

Subject: Vote YES on Bill C-234 to Support Family Farms\n\nDear [Representative Name],\n\nMy name is [Your Name], and I am a constituent in [Riding]. I'm writing\nto urge you to vote YES on Bill C-234, which would exempt farmers from\nthe carbon tax on natural gas and propane used for farming.\n\nFamily farms are the backbone of our food system, yet they face rising\ncosts that threaten their viability. This bill would save farmers an\naverage of $14,000 per year, helping them stay in business and keep\nfood prices stable.\n\nI'm specifically asking you to:\n1. Vote YES when Bill C-234 comes to the floor\n2. Speak publicly in support of family farms\n3. Oppose any amendments that weaken the bill\n\nFarming is already a low-margin business. Every dollar counts. Please\nsupport our farmers by supporting this bill.\n\nThank you for considering my views. I look forward to hearing your\nposition on this important issue.\n\nSincerely,\n[Your Name]\n[Your Email]\n[Your Phone]\n

What makes this email effective:

"},{"location":"v2/user-guides/campaign-manager-guide/#creating-a-campaign","title":"Creating a Campaign","text":""},{"location":"v2/user-guides/campaign-manager-guide/#basic-campaign-setup","title":"Basic Campaign Setup","text":"

To create a new campaign:

  1. Navigate to Influence > Campaigns
  2. Click \"Create Campaign\"
  3. Fill in the form (detailed below)
  4. Click \"Create\"

Your campaign starts in DRAFT status (not published).

"},{"location":"v2/user-guides/campaign-manager-guide/#campaign-fields","title":"Campaign Fields","text":""},{"location":"v2/user-guides/campaign-manager-guide/#title","title":"Title","text":"

What it is: Public-facing campaign name

Best practices:

Examples:

"},{"location":"v2/user-guides/campaign-manager-guide/#slug","title":"Slug","text":"

What it is: URL-friendly identifier, auto-generated from title

Format: lowercase, hyphens for spaces, no special characters

Examples:

Used in URL: https://yoursite.org/campaigns/protect-our-forests

Slug Uniqueness

Slugs must be unique. If you try to use a duplicate, the system will add a number (e.g., protect-our-forests-2).

"},{"location":"v2/user-guides/campaign-manager-guide/#description","title":"Description","text":"

What it is: Campaign overview shown on listing page and campaign detail page

Best practices:

Example:

<p>Ancient forests in our region are being clear-cut at an alarming rate.\nThese forests provide habitat for endangered species, clean our air and\nwater, and offer recreational spaces for our communities.</p>\n\n<p><strong>Tell your MPP to enact a moratorium on old-growth logging\nuntil sustainable forestry practices are in place.</strong></p>\n
"},{"location":"v2/user-guides/campaign-manager-guide/#government-level","title":"Government Level","text":"

What it is: Which level of government to target for representative lookup

Options:

You can select multiple levels if your issue spans jurisdictions.

Example scenarios:

"},{"location":"v2/user-guides/campaign-manager-guide/#email-subject","title":"Email Subject","text":"

What it is: Subject line for emails citizens send to representatives

Best practices:

Variables available:

Examples:

"},{"location":"v2/user-guides/campaign-manager-guide/#email-body","title":"Email Body","text":"

What it is: The email message template citizens send

Structure:

Greeting (uses {{REP_NAME}})\n\nOpening paragraph: Who I am, why I'm writing\n\nBody paragraphs: Issue explanation, impact, evidence\n\nSpecific asks: Numbered list of actions\n\nClosing: Thank you, request for response\n\nSignature (uses {{USER_NAME}}, {{USER_EMAIL}}, etc.)\n\nOptional: User's personal message ({{USER_MESSAGE}})\n

Variables available:

Tips:

"},{"location":"v2/user-guides/campaign-manager-guide/#cover-photo-optional","title":"Cover Photo (Optional)","text":"

What it is: Image shown on campaign listing and detail pages

Best practices:

Upload: Provide URL to image (must host image externally or use media library)

"},{"location":"v2/user-guides/campaign-manager-guide/#configuring-feature-flags","title":"Configuring Feature Flags","text":"

Feature flags control campaign functionality. Here's a detailed guide on when to use each:

"},{"location":"v2/user-guides/campaign-manager-guide/#core-feature-flags","title":"Core Feature Flags","text":""},{"location":"v2/user-guides/campaign-manager-guide/#1-published","title":"1. Published","text":"

What it does: Makes campaign visible on public listing page

When to enable:

When to disable:

Unpublishing

Unpublishing a campaign removes it from the public listing but preserves all data (emails sent, responses, etc.). The campaign page URL still works for anyone with a direct link.

"},{"location":"v2/user-guides/campaign-manager-guide/#2-featured","title":"2. Featured","text":"

What it does: Displays campaign prominently at top of listing page

When to enable:

Best practices:

"},{"location":"v2/user-guides/campaign-manager-guide/#3-has-response-wall","title":"3. Has Response Wall","text":"

What it does: Allows citizens to share personal stories publicly after emailing

When to enable:

When to disable:

Moderation required: Unless auto_approve_responses is enabled, all responses must be manually approved.

"},{"location":"v2/user-guides/campaign-manager-guide/#advanced-feature-flags","title":"Advanced Feature Flags","text":""},{"location":"v2/user-guides/campaign-manager-guide/#4-collect-phone-numbers","title":"4. Collect Phone Numbers","text":"

What it does: Adds optional phone number field to campaign form

When to enable:

When to disable:

Data usage: Phone numbers are stored in campaign responses and visible to admins.

"},{"location":"v2/user-guides/campaign-manager-guide/#5-track-calls","title":"5. Track Calls","text":"

What it does: Adds \"I called my representative\" button and tracks call attempts

When to enable:

How it works:

"},{"location":"v2/user-guides/campaign-manager-guide/#6-require-verification","title":"6. Require Verification","text":"

What it does: Sends verification email before recording email send

When to enable:

When to disable:

How it works:

  1. User fills out form and clicks \"Send\"
  2. System sends verification email
  3. User clicks link in email
  4. Email to representative is sent
  5. Response is recorded

Recommended

Enable verification for all public campaigns to prevent spam and ensure data quality.

"},{"location":"v2/user-guides/campaign-manager-guide/#7-auto-approve-responses","title":"7. Auto Approve Responses","text":"

What it does: Response wall submissions appear immediately without moderation

When to enable:

When to disable:

Moderation Recommended

Most public campaigns should NOT auto-approve. Manual moderation ensures quality and prevents abuse.

"},{"location":"v2/user-guides/campaign-manager-guide/#8-allow-anonymous","title":"8. Allow Anonymous","text":"

What it does: Citizens can send emails without creating an account

When to enable:

When to disable:

Trade-offs:

"},{"location":"v2/user-guides/campaign-manager-guide/#9-custom-recipients","title":"9. Custom Recipients","text":"

What it does: Override representative lookup and send to specific email addresses

When to enable:

How to use:

  1. Enable flag
  2. Enter comma-separated email addresses in custom_recipient_emails field
  3. Optionally enter custom recipient names in custom_recipient_names field

Example:

custom_recipient_emails: ceo@corporation.com,president@university.edu\ncustom_recipient_names: CEO John Smith,University President Jane Doe\n

All emails will go to these addresses instead of postal code lookup.

"},{"location":"v2/user-guides/campaign-manager-guide/#10-show-progress-bar","title":"10. Show Progress Bar","text":"

What it does: Displays progress bar showing emails sent toward goal

When to enable:

How to use:

  1. Enable flag
  2. Set email_goal field (e.g., 1000)
  3. Progress bar appears on campaign page showing current count / goal

Example display:

[=========>           ] 734 / 1,000 emails sent (73%)\n

Set Realistic Goals

Research similar campaigns to set achievable goals. Falling short publicly can be demotivating.

"},{"location":"v2/user-guides/campaign-manager-guide/#11-disable-after-date","title":"11. Disable After Date","text":"

What it does: Automatically unpublish campaign after specified date

When to enable:

How to use:

  1. Enable flag
  2. Set disable_date field (date picker)
  3. Campaign automatically unpublishes at midnight on that date

Example:

Legislative vote is March 15. Set disable_date to March 15, 2024. Campaign automatically closes that day.

"},{"location":"v2/user-guides/campaign-manager-guide/#12-enable-comments","title":"12. Enable Comments","text":"

What it does: Allows comments on response wall entries (discussion threads)

When to enable:

When to disable:

Experimental Feature

Comments require additional moderation. Consider carefully before enabling.

"},{"location":"v2/user-guides/campaign-manager-guide/#email-template-best-practices","title":"Email Template Best Practices","text":""},{"location":"v2/user-guides/campaign-manager-guide/#writing-effective-subject-lines","title":"Writing Effective Subject Lines","text":"

Do:

Don't:

Examples:

Good Why \"Vote YES on Bill C-123 for climate action\" Clear, specific, action-oriented \"Support funding for public transit\" Simple, direct ask \"Protect our forests from logging\" Emotional appeal, clear issue Bad Why \"URGENT: Read this NOW!!!\" Spammy, no substance \"About the issue we discussed\" Vague, no context \"I am writing to you regarding...\" Wordy, buries the lede"},{"location":"v2/user-guides/campaign-manager-guide/#structuring-the-email-body","title":"Structuring the Email Body","text":"

Recommended structure:

1. Greeting\n   Dear {{REP_NAME}},\n\n2. Introduction (1 sentence)\n   Who you are, where you live\n\n3. Main ask (1 sentence)\n   What you want them to do\n\n4. Context (2-3 sentences)\n   Why it matters, impact, urgency\n\n5. Evidence (2-3 sentences)\n   Facts, statistics, expert opinions\n\n6. Specific actions (numbered list)\n   Exactly what you want them to do\n\n7. Closing (1-2 sentences)\n   Thank you, request for response\n\n8. Signature\n   {{USER_NAME}}\n   {{USER_EMAIL}}\n\n9. Personal message (optional)\n   {{USER_MESSAGE}}\n
"},{"location":"v2/user-guides/campaign-manager-guide/#using-variables-effectively","title":"Using Variables Effectively","text":"

Available variables:

Variable Description Example Output {{USER_NAME}} Sender's full name \"John Smith\" {{USER_EMAIL}} Sender's email \"john@example.com\" {{USER_PHONE}} Sender's phone \"555-1234\" {{REP_NAME}} Representative's name \"Hon. Jane Doe\" {{REP_EMAIL}} Representative's email \"jane.doe@parl.gc.ca\" {{REP_TITLE}} Representative's title \"Member of Parliament\" {{USER_MESSAGE}} Custom message (whatever user typed)

Best practices:

  1. Always use {{REP_NAME}} in greeting \u2014 Personalizes email
  2. Include {{USER_NAME}} in signature \u2014 Shows it's from a real person
  3. Add {{USER_MESSAGE}} at end \u2014 Allows personalization
  4. Use {{REP_TITLE}} for variety \u2014 Avoid repeating \"Member of Parliament\"

Example usage:

Dear {{REP_NAME}},\n\nMy name is {{USER_NAME}}, and I am a constituent in your riding. As a\n{{REP_TITLE}}, you have the power to make a difference on this issue.\n\n[... campaign message ...]\n\nI look forward to hearing your position on this matter. You can reach me\nat {{USER_EMAIL}}.\n\nSincerely,\n{{USER_NAME}}\n\n---\n\n{{USER_MESSAGE}}\n
"},{"location":"v2/user-guides/campaign-manager-guide/#html-formatting-tips","title":"HTML Formatting Tips","text":"

The email editor supports HTML. Use formatting to improve readability:

Headings:

<h3>Why This Matters</h3>\n

Bold text:

<strong>Vote YES on Bill C-123</strong>\n

Lists:

<p>I'm asking you to:</p>\n<ol>\n  <li>Vote YES when the bill comes to the floor</li>\n  <li>Speak publicly in support</li>\n  <li>Oppose weakening amendments</li>\n</ol>\n

Links:

<a href=\"https://example.com/research\">Read the full study here</a>\n

Line breaks:

<p>First paragraph.</p>\n<p>Second paragraph.</p>\n

Email Client Compatibility

Avoid complex CSS or JavaScript. Stick to basic HTML tags (p, strong, em, ul, ol, a). Many email clients strip advanced formatting.

"},{"location":"v2/user-guides/campaign-manager-guide/#publishing-your-campaign","title":"Publishing Your Campaign","text":""},{"location":"v2/user-guides/campaign-manager-guide/#pre-launch-checklist","title":"Pre-Launch Checklist","text":"

Before publishing, verify:

To send a test email:

  1. Edit the campaign
  2. Scroll to email section
  3. Click \"Send Test Email\"
  4. Enter your email address
  5. Check your inbox

The test email uses sample data for variables.

"},{"location":"v2/user-guides/campaign-manager-guide/#publishing","title":"Publishing","text":"

To publish:

  1. Edit the campaign
  2. Toggle \"Published\" flag to ON
  3. Click \"Save\"

The campaign is now live at /campaigns/[slug].

"},{"location":"v2/user-guides/campaign-manager-guide/#promoting-your-campaign","title":"Promoting Your Campaign","text":"

Promotion channels:

  1. Direct link: Share https://yoursite.org/campaigns/protect-our-forests
  2. Email newsletter: Include in your regular newsletter
  3. Social media: Post on Facebook, Twitter, Instagram with link
  4. Website: Add to your main website's homepage or action page
  5. Partner organizations: Ask allies to share
  6. Earned media: Pitch to journalists, bloggers

Sample social media post:

\ud83c\udf32 Our forests are in danger. Tell your MPP to stop old-growth logging.\n\n\ud83d\udce7 Send an email in under 2 minutes: [link]\n\nSo far, [X] people have taken action. Will you join them?\n\n#ProtectOurForests #ClimateAction\n

Sample email newsletter:

Subject: Take Action: Protect Our Forests\n\nHi [Name],\n\nAncient forests in our region are being clear-cut at an alarming rate.\nBut we can stop this.\n\n[Your MPP's name] has the power to enact a moratorium on old-growth\nlogging. We need you to tell them this matters to you.\n\n[CALL TO ACTION BUTTON: Send Your Email Now]\n\nIt takes less than 2 minutes. Over [X] people have already sent emails.\nTogether, we can make a difference.\n\nThank you for taking action,\n[Your organization]\n
"},{"location":"v2/user-guides/campaign-manager-guide/#monitoring-performance","title":"Monitoring Performance","text":""},{"location":"v2/user-guides/campaign-manager-guide/#campaign-email-statistics","title":"Campaign Email Statistics","text":"

To view email stats:

  1. Navigate to Influence > Campaigns
  2. Click \"Emails\" button for your campaign

The drawer shows:

Overall statistics:

Email list table:

Screenshot placeholder: Campaign Emails drawer showing statistics and email list

"},{"location":"v2/user-guides/campaign-manager-guide/#understanding-email-status","title":"Understanding Email Status","text":"

PENDING:

SENT:

FAILED:

Retry failed emails:

  1. Click \"Retry Failed\" button
  2. System re-queues failed emails
  3. Check again in 10 minutes

Representative Emails

Representative email addresses come from the Represent API. If many emails fail to a specific representative, the database may be outdated. Contact Represent API maintainers.

"},{"location":"v2/user-guides/campaign-manager-guide/#response-wall-statistics","title":"Response Wall Statistics","text":"

To view response wall stats:

  1. Navigate to Influence > Responses
  2. Filter by your campaign

Metrics:

Response rate:

Response rate = Responses / Emails sent\n

Typical response rates:

"},{"location":"v2/user-guides/campaign-manager-guide/#email-queue-health","title":"Email Queue Health","text":"

To monitor the queue:

  1. Navigate to Influence > Email Queue

Key metrics:

Queue controls:

Queue Pausing

Only pause the queue if SMTP is broken or you're changing email configuration. Citizens expect immediate sends.

"},{"location":"v2/user-guides/campaign-manager-guide/#moderating-responses","title":"Moderating Responses","text":""},{"location":"v2/user-guides/campaign-manager-guide/#response-moderation-workflow","title":"Response Moderation Workflow","text":"

To moderate responses:

  1. Navigate to Influence > Responses
  2. Filter to Status: PENDING
  3. Review each response
  4. Approve or reject

Moderation decisions:

Approve if:

Reject if:

Delete if:

"},{"location":"v2/user-guides/campaign-manager-guide/#reviewing-a-response","title":"Reviewing a Response","text":"

To review in detail:

  1. Click \"View\" in Actions column
  2. Read full response text
  3. Check submitter info (name, email, timestamp)
  4. Decide: Approve, Reject, or Delete

Response detail shows:

Actions:

Editing Responses

Only edit responses to fix obvious typos or remove sensitive info (phone numbers, addresses). Don't change meaning.

"},{"location":"v2/user-guides/campaign-manager-guide/#moderation-best-practices","title":"Moderation Best Practices","text":"

Speed matters:

Consistency:

Encourage quality:

Handle edge cases:

"},{"location":"v2/user-guides/campaign-manager-guide/#responding-to-moderation-issues","title":"Responding to Moderation Issues","text":"

If you accidentally reject a good response:

  1. Find the response in table
  2. Change status from REJECTED to APPROVED
  3. Response immediately appears on response wall

If inappropriate content slips through:

  1. Find the response
  2. Change status from APPROVED to REJECTED (or delete)
  3. Response immediately removed from public view

If user complains about rejection:

  1. Review the response again
  2. If rejection was correct, explain your moderation policy
  3. If rejection was incorrect, approve and apologize
  4. Consider revising moderation guidelines to prevent future issues
"},{"location":"v2/user-guides/campaign-manager-guide/#optimization-strategies","title":"Optimization Strategies","text":""},{"location":"v2/user-guides/campaign-manager-guide/#improving-email-conversion-rates","title":"Improving Email Conversion Rates","text":"

Conversion rate = Emails sent / Page visitors

Typical conversion rates:

Tactics to improve conversion:

"},{"location":"v2/user-guides/campaign-manager-guide/#1-simplify-the-form","title":"1. Simplify the Form","text":""},{"location":"v2/user-guides/campaign-manager-guide/#2-reduce-friction","title":"2. Reduce Friction","text":""},{"location":"v2/user-guides/campaign-manager-guide/#3-strengthen-the-call-to-action","title":"3. Strengthen the Call to Action","text":""},{"location":"v2/user-guides/campaign-manager-guide/#4-improve-email-template","title":"4. Improve Email Template","text":""},{"location":"v2/user-guides/campaign-manager-guide/#5-add-trust-signals","title":"5. Add Trust Signals","text":""},{"location":"v2/user-guides/campaign-manager-guide/#ab-testing","title":"A/B Testing","text":"

Test different versions of your campaign to find what works best.

Elements to test:

  1. Email subject line
  2. Action-oriented vs question
  3. Include bill number vs generic
  4. Urgent vs neutral tone

  5. Call to action

  6. \"Send Email\" vs \"Take Action\" vs \"Email Your MP\"
  7. Button color (blue vs red vs green)
  8. Button size

  9. Campaign description

  10. Short (1 sentence) vs detailed (3 paragraphs)
  11. Emotional appeal vs factual
  12. Include statistics vs stories

  13. Feature flags

  14. Email verification ON vs OFF
  15. Response wall ON vs OFF
  16. Progress bar ON vs OFF

How to A/B test:

  1. Create two versions of the campaign (duplicate the campaign)
  2. Change ONE variable (e.g., subject line)
  3. Send 50% of traffic to each version (promote both equally)
  4. After 100+ emails sent per version, compare conversion rates
  5. Keep the winner, discard the loser

Sample A/B test:

Version A: Subject line \"Support Bill C-123 for climate action\"\nResult: 100 emails sent from 1,000 visitors = 10% conversion\n\nVersion B: Subject line \"Vote YES on climate action \u2014 your MP is listening\"\nResult: 150 emails sent from 1,000 visitors = 15% conversion\n\nWinner: Version B (50% improvement)\nAction: Update Version A subject to match Version B\n
"},{"location":"v2/user-guides/campaign-manager-guide/#encouraging-response-wall-participation","title":"Encouraging Response Wall Participation","text":"

Response wall benefits:

Tactics to increase responses:

"},{"location":"v2/user-guides/campaign-manager-guide/#1-highlight-the-response-wall","title":"1. Highlight the Response Wall","text":""},{"location":"v2/user-guides/campaign-manager-guide/#2-reduce-friction_1","title":"2. Reduce Friction","text":""},{"location":"v2/user-guides/campaign-manager-guide/#3-provide-examples","title":"3. Provide Examples","text":""},{"location":"v2/user-guides/campaign-manager-guide/#4-incentivize-participation","title":"4. Incentivize Participation","text":""},{"location":"v2/user-guides/campaign-manager-guide/#5-moderate-quickly","title":"5. Moderate Quickly","text":""},{"location":"v2/user-guides/campaign-manager-guide/#boosting-upvotes","title":"Boosting Upvotes","text":"

Upvotes signal which responses resonate most with your community.

Tactics:

  1. Make upvoting easy: One-click, no login required
  2. Show upvote counts: Create competition
  3. Promote top responses: Share high-upvote responses on social
  4. Create urgency: \"Most upvoted response will be featured in our newsletter\"
"},{"location":"v2/user-guides/campaign-manager-guide/#reporting-and-analytics","title":"Reporting and Analytics","text":""},{"location":"v2/user-guides/campaign-manager-guide/#campaign-performance-report","title":"Campaign Performance Report","text":"

Key metrics to track:

Metric Formula Benchmark Total emails sent Count of SENT status N/A (goal-dependent) Conversion rate Emails / Page visitors 5-15% Response rate Responses / Emails sent 5-15% Upvote rate Upvotes / Responses 20-40% Email success rate SENT / (SENT + FAILED) > 95% Avg time to send Queue wait time < 5 minutes"},{"location":"v2/user-guides/campaign-manager-guide/#exporting-data","title":"Exporting Data","text":"

To export campaign data:

  1. Navigate to Influence > Campaigns
  2. Click \"Emails\" for your campaign
  3. Click \"Export CSV\"

CSV includes:

Use cases:

Response wall export:

  1. Navigate to Influence > Responses
  2. Filter by campaign
  3. Click \"Export CSV\"

CSV includes:

Use cases:

"},{"location":"v2/user-guides/campaign-manager-guide/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/user-guides/campaign-manager-guide/#low-email-conversion-rate","title":"Low Email Conversion Rate","text":"

Symptoms: Few people sending emails despite high traffic

Diagnostic questions:

  1. Is representative lookup working?
  2. Test with multiple postal codes
  3. Check representative cache (Influence > Representatives)

  4. Is the form too complex?

  5. Remove optional fields
  6. Simplify email template
  7. Disable verification

  8. Is the call to action clear?

  9. Review campaign description
  10. Check button text and prominence
  11. Add urgency or social proof

  12. Is trust an issue?

  13. Add organization branding
  14. Display privacy policy
  15. Explain what happens after they send

Solutions:

"},{"location":"v2/user-guides/campaign-manager-guide/#low-response-wall-participation","title":"Low Response Wall Participation","text":"

Symptoms: Emails being sent but few response wall submissions

Possible causes:

  1. Response wall not prominent
  2. Add section on campaign page highlighting response wall
  3. Show recent responses below email form

  4. Friction too high

  5. Require verification \u2192 people abandon
  6. Long approval delay \u2192 people think it didn't work

  7. No examples/social proof

  8. Empty response wall \u2192 people don't know what to share
  9. Seed with initial responses

Solutions:

"},{"location":"v2/user-guides/campaign-manager-guide/#emails-stuck-in-queue","title":"Emails Stuck in Queue","text":"

Symptoms: Emails remain in PENDING status for > 1 hour

Diagnostic steps:

  1. Check queue status: Influence > Email Queue
  2. Check SMTP configuration: Settings > Email Configuration
  3. Test email send: Settings > Send Test Email

Common causes:

  1. Queue worker not running
  2. Contact system administrator
  3. Restart api service

  4. SMTP credentials wrong

  5. Verify username/password in Settings
  6. Send test email to verify

  7. SMTP server rejecting

  8. Check spam/rate limits on SMTP server
  9. Contact email service provider

  10. Network issue

  11. Check API server connectivity
  12. Try different SMTP provider

Emergency solution:

"},{"location":"v2/user-guides/campaign-manager-guide/#high-email-failure-rate","title":"High Email Failure Rate","text":"

Symptoms: Many emails with FAILED status

Check error messages:

  1. \"Invalid recipient email\"
  2. Representative email is wrong in database
  3. Contact Represent API maintainers
  4. Use custom recipients as workaround

  5. \"SMTP authentication failed\"

  6. Wrong SMTP username/password
  7. Update in Settings > Email Configuration

  8. \"Connection timeout\"

  9. Network issue between API server and SMTP
  10. Contact system administrator

  11. \"Mailbox full\"

  12. Representative's email inbox is full
  13. Nothing you can do (contact representative's office)

  14. \"Spam filter rejected\"

  15. Email looks like spam
  16. Revise email template (less spammy language)
  17. Contact SMTP provider about reputation

Solutions:

"},{"location":"v2/user-guides/campaign-manager-guide/#related-documentation","title":"Related Documentation","text":"

Last updated: February 2026 (V2 complete)

"},{"location":"v2/user-guides/content-editor-guide/","title":"Content Editor Guide","text":""},{"location":"v2/user-guides/content-editor-guide/#overview","title":"Overview","text":"

As a Content Editor, you're responsible for creating and managing public-facing content in Changemaker Lite, including:

This guide will help you create professional, engaging content that drives participation in your campaigns and volunteer activities.

"},{"location":"v2/user-guides/content-editor-guide/#getting-started","title":"Getting Started","text":""},{"location":"v2/user-guides/content-editor-guide/#content-editor-access","title":"Content Editor Access","text":"

Content editing features are available to:

Landing pages and media library are typically managed by SUPER_ADMIN only.

"},{"location":"v2/user-guides/content-editor-guide/#content-areas","title":"Content Areas","text":"

1. Landing Pages (/app/pages)

2. Email Templates (/app/email-templates)

3. Media Library (/app/media/library, if enabled)

"},{"location":"v2/user-guides/content-editor-guide/#creating-landing-pages","title":"Creating Landing Pages","text":""},{"location":"v2/user-guides/content-editor-guide/#landing-page-overview","title":"Landing Page Overview","text":"

Landing pages are custom web pages published at /p/[slug]. Use them for:

"},{"location":"v2/user-guides/content-editor-guide/#creating-a-new-page","title":"Creating a New Page","text":"

To create a landing page:

  1. Navigate to Content > Landing Pages
  2. Click \"Create Page\"
  3. Fill in page details:
  4. Title: Page title (shown in browser tab, used for SEO)
  5. Slug: URL identifier (e.g., about-us \u2192 /p/about-us)
  6. Description: Meta description for SEO (160 characters max)
  7. Status: DRAFT or PUBLISHED
  8. Click \"Create\"
  9. Click \"Edit\" to open the page editor

Screenshot placeholder: Create Page modal showing title, slug, description, and status fields

"},{"location":"v2/user-guides/content-editor-guide/#page-editor-overview","title":"Page Editor Overview","text":"

The page editor has two modes:

Visual Mode (default):

Code Mode:

Switch modes using the tabs at the top of the editor.

Screenshot placeholder: Page editor showing Visual/Code mode tabs and toolbar

Desktop Only

The page editor is designed for desktop use (minimum 1024px width). Mobile users will see a warning to switch to desktop.

"},{"location":"v2/user-guides/content-editor-guide/#using-the-visual-editor","title":"Using the Visual Editor","text":""},{"location":"v2/user-guides/content-editor-guide/#editor-interface","title":"Editor Interface","text":"

The visual editor has three main areas:

1. Canvas (center):

2. Block Toolbar (left):

3. Settings Panel (right):

Screenshot placeholder: Visual editor showing block toolbar, canvas, and settings panel

"},{"location":"v2/user-guides/content-editor-guide/#adding-blocks","title":"Adding Blocks","text":"

To add a block:

  1. Find block in left toolbar (or search)
  2. Drag block onto canvas
  3. Drop where you want it

Available block categories:

Layout:

Text:

Media:

Forms:

Components (custom blocks):

Screenshot placeholder: Block toolbar showing categories and block preview thumbnails

"},{"location":"v2/user-guides/content-editor-guide/#configuring-blocks","title":"Configuring Blocks","text":"

To configure a block:

  1. Click the block on canvas (selects it)
  2. Settings panel opens on right
  3. Adjust settings (varies by block type)

Common settings:

Style tab:

Settings tab (varies by block):

Screenshot placeholder: Settings panel showing style options for a selected heading block

"},{"location":"v2/user-guides/content-editor-guide/#styling-blocks","title":"Styling Blocks","text":"

To change text color:

  1. Select text block
  2. Settings panel > Style tab
  3. Color picker under Typography
  4. Choose color or enter hex code

To change background:

  1. Select section or container block
  2. Settings panel > Style tab
  3. Background section
  4. Choose color, image, or gradient

To adjust spacing:

  1. Select block
  2. Settings panel > Style tab
  3. Margin/Padding section
  4. Adjust top, right, bottom, left values

Screenshot placeholder: Background settings showing color picker, image upload, and gradient options

"},{"location":"v2/user-guides/content-editor-guide/#using-pre-built-components","title":"Using Pre-Built Components","text":"

Changemaker Lite includes pre-built components for common page sections:

"},{"location":"v2/user-guides/content-editor-guide/#hero-component","title":"Hero Component","text":"

What it is: Large header section with background image, headline, and call-to-action button

How to use:

  1. Drag Hero block from Components category
  2. Click headline to edit text
  3. Click button to edit text and link
  4. Select block, then in settings:
  5. Upload background image
  6. Adjust overlay opacity
  7. Change text color

Screenshot placeholder: Hero component on canvas showing headline, subheading, and CTA button

"},{"location":"v2/user-guides/content-editor-guide/#features-component","title":"Features Component","text":"

What it is: Three-column grid showcasing features or benefits

How to use:

  1. Drag Features block onto canvas
  2. Click each feature to edit:
  3. Icon (Font Awesome icon name)
  4. Heading
  5. Description
  6. Adjust colors and spacing in settings panel

Screenshot placeholder: Features component showing three columns with icons, headings, and text

"},{"location":"v2/user-guides/content-editor-guide/#testimonial-component","title":"Testimonial Component","text":"

What it is: Quote with author photo and name

How to use:

  1. Drag Testimonial block onto canvas
  2. Click quote text to edit
  3. Click author name to edit
  4. Upload author photo in settings panel
"},{"location":"v2/user-guides/content-editor-guide/#call-to-action-component","title":"Call to Action Component","text":"

What it is: Centered section with headline and button

How to use:

  1. Drag Call to Action block onto canvas
  2. Edit headline and description
  3. Edit button text and link
  4. Adjust background color
"},{"location":"v2/user-guides/content-editor-guide/#saving-your-page","title":"Saving Your Page","text":"

To save changes:

Method 1: Keyboard shortcut

Method 2: Save button

Auto-save:

Save Often

Use Ctrl+S frequently. Browser crashes or network issues can cause unsaved work to be lost.

Screenshot placeholder: Save button in editor toolbar

"},{"location":"v2/user-guides/content-editor-guide/#using-the-code-editor","title":"Using the Code Editor","text":""},{"location":"v2/user-guides/content-editor-guide/#switching-to-code-mode","title":"Switching to Code Mode","text":"

To switch to code editor:

  1. Click \"Code\" tab at top of editor
  2. HTML code appears in text editor
  3. Edit HTML directly
  4. Click \"Visual\" tab to return to visual mode

When to use code mode:

Screenshot placeholder: Code editor showing HTML markup in text editor

"},{"location":"v2/user-guides/content-editor-guide/#html-structure","title":"HTML Structure","text":"

Basic page structure:

<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <title>Page Title</title>\n  <style>\n    /* CSS goes here */\n  </style>\n</head>\n<body>\n  <!-- Page content goes here -->\n</body>\n</html>\n

Recommended structure:

<body>\n  <!-- Hero Section -->\n  <section class=\"hero\">\n    <h1>Welcome to Our Campaign</h1>\n    <p>Join us in making a difference.</p>\n    <a href=\"/campaigns/climate-action\" class=\"btn\">Take Action</a>\n  </section>\n\n  <!-- Features Section -->\n  <section class=\"features\">\n    <div class=\"container\">\n      <div class=\"row\">\n        <div class=\"col\">\n          <h3>Easy to Use</h3>\n          <p>Send emails in under 2 minutes.</p>\n        </div>\n        <div class=\"col\">\n          <h3>High Impact</h3>\n          <p>Your voice reaches decision-makers.</p>\n        </div>\n        <div class=\"col\">\n          <h3>Community</h3>\n          <p>Join thousands of advocates.</p>\n        </div>\n      </div>\n    </div>\n  </section>\n</body>\n
"},{"location":"v2/user-guides/content-editor-guide/#adding-custom-css","title":"Adding Custom CSS","text":"

To add custom styles:

  1. In code mode, add a <style> block in the <head>:
<head>\n  <style>\n    .hero {\n      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n      color: white;\n      padding: 100px 20px;\n      text-align: center;\n    }\n\n    .hero h1 {\n      font-size: 3rem;\n      margin-bottom: 20px;\n    }\n\n    .btn {\n      background: #ff6b6b;\n      color: white;\n      padding: 15px 40px;\n      text-decoration: none;\n      border-radius: 5px;\n      display: inline-block;\n      margin-top: 20px;\n    }\n\n    .btn:hover {\n      background: #ee5a52;\n    }\n  </style>\n</head>\n
"},{"location":"v2/user-guides/content-editor-guide/#using-variables","title":"Using Variables","text":"

Landing pages support variable interpolation:

Available variables:

Example usage:

<p>Welcome to {{SITE_NAME}}, {{USER_NAME}}!</p>\n

Renders as:

Welcome to Community Action Network, John Smith!\n
"},{"location":"v2/user-guides/content-editor-guide/#keyboard-shortcuts-in-code-mode","title":"Keyboard Shortcuts in Code Mode","text":""},{"location":"v2/user-guides/content-editor-guide/#publishing-pages","title":"Publishing Pages","text":""},{"location":"v2/user-guides/content-editor-guide/#publishing-workflow","title":"Publishing Workflow","text":"

Draft \u2192 Published:

  1. Create page (status: DRAFT)
  2. Build page in editor
  3. Preview page (see below)
  4. Publish page (change status to PUBLISHED)

Draft pages:

Published pages:

"},{"location":"v2/user-guides/content-editor-guide/#previewing-pages","title":"Previewing Pages","text":"

To preview a page before publishing:

  1. Save the page (Ctrl+S)
  2. Click \"Preview\" button in editor toolbar
  3. Page opens in new tab at /p/[slug]?preview=true

OR:

  1. Navigate to Content > Landing Pages
  2. Click page title to view published version

Screenshot placeholder: Preview button in editor toolbar

"},{"location":"v2/user-guides/content-editor-guide/#publishing-a-page","title":"Publishing a Page","text":"

To publish a draft page:

  1. Navigate to Content > Landing Pages
  2. Find the page in the table
  3. Click \"Edit\" in Actions column
  4. Change status from DRAFT to PUBLISHED
  5. Click \"Save\"

To unpublish a page:

  1. Change status from PUBLISHED to DRAFT
  2. Save

Unpublishing removes the page from public access but preserves all content.

"},{"location":"v2/user-guides/content-editor-guide/#seo-settings","title":"SEO Settings","text":"

To optimize for search engines:

  1. Edit the page
  2. Fill in SEO fields:
  3. Title: Page title (shown in search results, max 60 characters)
  4. Description: Meta description (shown in search results, max 160 characters)
  5. Keywords: Comma-separated keywords (e.g., \"climate action, advocacy, environment\")
  6. OG Image: Social media share image (Facebook, Twitter)

Best practices:

Screenshot placeholder: SEO settings form showing title, description, keywords, and OG image fields

"},{"location":"v2/user-guides/content-editor-guide/#mkdocs-export","title":"MkDocs Export","text":"

What it is: Export landing page as Jinja2 template for MkDocs (static site generator)

Use case: Publish landing pages on your static documentation site

To export:

  1. Navigate to Content > Landing Pages
  2. Click \"Export\" in Actions column
  3. Choose export format:
  4. Jinja2 Template: Wraps HTML in MkDocs Material theme layout
  5. Standalone HTML: Raw HTML (no wrapper)
  6. File is saved to MkDocs docs/overrides/ directory
  7. Access via MkDocs site navigation

Screenshot placeholder: Export modal showing Jinja2/Standalone options

"},{"location":"v2/user-guides/content-editor-guide/#managing-email-templates","title":"Managing Email Templates","text":""},{"location":"v2/user-guides/content-editor-guide/#email-template-overview","title":"Email Template Overview","text":"

Email templates control the content and formatting of system-generated emails:

System templates:

Custom templates:

"},{"location":"v2/user-guides/content-editor-guide/#email-template-structure","title":"Email Template Structure","text":"

Each template has three parts:

1. Subject Line

2. HTML Body

3. Plain Text Body

"},{"location":"v2/user-guides/content-editor-guide/#editing-an-email-template","title":"Editing an Email Template","text":"

To edit a template:

  1. Navigate to Content > Email Templates
  2. Click \"Edit\" for the template you want to modify
  3. Edit subject, HTML body, and/or plain text body
  4. Click \"Preview\" to see rendered email
  5. Click \"Save\"

Screenshot placeholder: Email template editor showing subject field, HTML editor, and plain text editor

"},{"location":"v2/user-guides/content-editor-guide/#using-variables-in-templates","title":"Using Variables in Templates","text":"

Variables are placeholders that get replaced with real data when the email is sent.

Available variables:

User variables:

Shift variables:

Campaign variables:

System variables:

Example template:

Subject:

Welcome to {{SITE_NAME}}, {{USER_NAME}}!\n

HTML Body:

<h1>Welcome, {{USER_NAME}}!</h1>\n\n<p>Thank you for joining {{SITE_NAME}}. We're excited to have you as part of our community.</p>\n\n<p>Here's what you can do next:</p>\n<ul>\n  <li><a href=\"{{SITE_URL}}/campaigns\">Take action on a campaign</a></li>\n  <li><a href=\"{{SITE_URL}}/shifts\">Sign up for a volunteer shift</a></li>\n  <li><a href=\"{{SITE_URL}}/app\">Explore your dashboard</a></li>\n</ul>\n\n<p>If you have questions, reply to this email or visit our <a href=\"{{SITE_URL}}/docs\">help center</a>.</p>\n\n<p>Together, we can make a difference!</p>\n\n<p>\u2014 The {{SITE_NAME}} Team</p>\n

Plain Text Body:

Welcome, {{USER_NAME}}!\n\nThank you for joining {{SITE_NAME}}. We're excited to have you as part of our community.\n\nHere's what you can do next:\n- Take action on a campaign: {{SITE_URL}}/campaigns\n- Sign up for a volunteer shift: {{SITE_URL}}/shifts\n- Explore your dashboard: {{SITE_URL}}/app\n\nIf you have questions, reply to this email or visit our help center: {{SITE_URL}}/docs.\n\nTogether, we can make a difference!\n\n\u2014 The {{SITE_NAME}} Team\n
"},{"location":"v2/user-guides/content-editor-guide/#html-email-best-practices","title":"HTML Email Best Practices","text":"

Do:

Don't:

"},{"location":"v2/user-guides/content-editor-guide/#testing-email-templates","title":"Testing Email Templates","text":"

To test a template:

  1. Click \"Send Test Email\" button in editor
  2. Enter your email address
  3. Click \"Send\"
  4. Check your inbox (may take 1-2 minutes)

The test email uses sample data for variables:

Test in multiple email clients:

Look for:

Screenshot placeholder: Send Test Email modal showing email address input and send button

"},{"location":"v2/user-guides/content-editor-guide/#managing-the-media-library","title":"Managing the Media Library","text":"

Optional Feature

Media features must be enabled via Settings > Feature Toggles > ENABLE_MEDIA_FEATURES. Contact your administrator if this option is not visible.

"},{"location":"v2/user-guides/content-editor-guide/#media-library-overview","title":"Media Library Overview","text":"

The media library allows you to:

Use cases:

"},{"location":"v2/user-guides/content-editor-guide/#uploading-videos","title":"Uploading Videos","text":"

To upload a video:

  1. Navigate to Content > Media > Library
  2. Click \"Upload Video\" button (top-right)
  3. Either:
  4. Drag and drop video file into upload area, OR
  5. Click to browse and select file
  6. Fill in metadata (see below)
  7. Click \"Upload\"

Screenshot placeholder: Upload Video modal showing drag-drop area and metadata form

Supported formats:

File size limit: 10 GB per file

Upload time: Varies by file size and connection speed. A 1 GB file takes ~5-10 minutes on typical broadband.

"},{"location":"v2/user-guides/content-editor-guide/#video-metadata","title":"Video Metadata","text":"

Metadata fields:

Title (required):

Description (optional):

Producer (optional):

Creator (optional):

Tags (optional):

Directory (optional):

Screenshot placeholder: Metadata form showing title, description, producer, creator, tags, and directory fields

"},{"location":"v2/user-guides/content-editor-guide/#automatic-metadata-extraction","title":"Automatic Metadata Extraction","text":"

When you upload a video, the system automatically extracts:

Quality detection:

Orientation detection:

You cannot edit these fields manually\u2014they're extracted automatically.

"},{"location":"v2/user-guides/content-editor-guide/#organizing-videos","title":"Organizing Videos","text":"

Directory structure:

Use directories to organize videos by:

Example directory structure:

events/\n  2024/\n    rally-june.mp4\n    townhall-july.mp4\n  2023/\n    rally-september.mp4\ntestimonials/\n  climate/\n    jane-smith.mp4\n    john-doe.mp4\n  housing/\n    maria-garcia.mp4\neducational/\n  climate-101.mp4\n  how-to-canvass.mp4\n

To move videos between directories:

  1. Select videos in library (checkboxes)
  2. Choose \"Move\" from bulk actions
  3. Enter new directory path
  4. Click \"Move\"

Screenshot placeholder: Library showing directory tree sidebar and video grid

"},{"location":"v2/user-guides/content-editor-guide/#filtering-and-searching-videos","title":"Filtering and Searching Videos","text":"

To find videos:

Search:

Filters:

Sort:

Screenshot placeholder: Library filters showing directory dropdown, quality checkboxes, and sort options

"},{"location":"v2/user-guides/content-editor-guide/#editing-video-metadata","title":"Editing Video Metadata","text":"

To edit a video:

  1. Click on video thumbnail (or click \"Edit\" in actions menu)
  2. Edit metadata fields
  3. Click \"Save\"

Editable fields:

Non-editable fields (auto-extracted):

"},{"location":"v2/user-guides/content-editor-guide/#deleting-videos","title":"Deleting Videos","text":"

To delete a video:

  1. Select video in library
  2. Click \"Delete\" (trash icon)
  3. Confirm deletion

Permanent Deletion

Deleting a video is permanent. The video file is removed from the server and cannot be recovered.

Locked videos cannot be deleted (unlock first).

"},{"location":"v2/user-guides/content-editor-guide/#locking-videos","title":"Locking Videos","text":"

What is locking?

Locked videos cannot be:

When to lock:

To lock a video:

  1. Select video
  2. Click \"Lock\" (padlock icon)

To unlock:

  1. Select locked video
  2. Click \"Unlock\"

Screenshot placeholder: Video card showing lock icon badge

"},{"location":"v2/user-guides/content-editor-guide/#sharing-videos-publicly","title":"Sharing Videos Publicly","text":""},{"location":"v2/user-guides/content-editor-guide/#public-media-gallery","title":"Public Media Gallery","text":"

The public media gallery (/media) showcases videos to the public. It's organized by categories.

Categories:

"},{"location":"v2/user-guides/content-editor-guide/#sharing-videos","title":"Sharing Videos","text":"

To share videos publicly:

  1. Navigate to Content > Media > Shared Media
  2. Click \"Share Videos\" button
  3. Select videos from library (search, filter, select)
  4. Choose category (TESTIMONIAL, EVENT, EDUCATIONAL, PROMOTIONAL)
  5. Click \"Share\"

Videos immediately appear on public gallery at /media.

Screenshot placeholder: Share Videos modal showing library selector, category dropdown, and share button

"},{"location":"v2/user-guides/content-editor-guide/#managing-shared-media","title":"Managing Shared Media","text":"

To view shared videos:

  1. Navigate to Content > Media > Shared Media

Table shows:

To unshare videos:

  1. Select videos in table
  2. Click \"Unshare\"
  3. Confirm

Videos are removed from public gallery but remain in library.

To change category:

  1. Click \"Edit\" for video
  2. Select new category
  3. Click \"Save\"
"},{"location":"v2/user-guides/content-editor-guide/#public-gallery-customization","title":"Public Gallery Customization","text":"

Gallery settings (managed by admin):

Ask your administrator to configure these settings.

"},{"location":"v2/user-guides/content-editor-guide/#content-best-practices","title":"Content Best Practices","text":""},{"location":"v2/user-guides/content-editor-guide/#writing-for-the-web","title":"Writing for the Web","text":"

Scannable:

Actionable:

Accessible:

"},{"location":"v2/user-guides/content-editor-guide/#mobile-optimization","title":"Mobile Optimization","text":"

Mobile traffic is 50-70% of web traffic. Optimize for mobile:

Responsive design:

Touch targets:

Load time:

Readability:

"},{"location":"v2/user-guides/content-editor-guide/#seo-optimization","title":"SEO Optimization","text":"

On-page SEO:

  1. Title tag: Include primary keyword, under 60 characters
  2. Meta description: Compelling, includes keyword, under 160 characters
  3. Headings: Use H1 for main title, H2 for sections, H3 for subsections
  4. Keywords: Use naturally in content (don't stuff)
  5. Internal links: Link to other pages on your site
  6. External links: Link to authoritative sources
  7. Image alt text: Describe images for screen readers and SEO

Technical SEO:

"},{"location":"v2/user-guides/content-editor-guide/#accessibility","title":"Accessibility","text":"

WCAG 2.1 Level AA compliance:

Perceivable:

Operable:

Understandable:

Robust:

"},{"location":"v2/user-guides/content-editor-guide/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/user-guides/content-editor-guide/#landing-pages","title":"Landing Pages","text":"

Issue: Page editor won't load

Solutions:

  1. Check browser console for errors (F12)
  2. Try different browser (Chrome recommended)
  3. Clear browser cache (Ctrl+Shift+Delete)
  4. Disable browser extensions (ad blockers may interfere)

Issue: Changes not saving

Solutions:

  1. Check internet connection
  2. Try Ctrl+S (keyboard shortcut)
  3. Check browser console for errors
  4. Try refreshing and re-editing

Issue: Page looks different when published

Causes:

Solutions:

  1. Hard refresh published page (Ctrl+Shift+R)
  2. Test in incognito/private window
  3. Clear browser cache
"},{"location":"v2/user-guides/content-editor-guide/#email-templates","title":"Email Templates","text":"

Issue: Variables not replacing

Symptoms: Email shows {{USER_NAME}} instead of actual name

Causes:

Solutions:

  1. Check variable spelling (case-sensitive)
  2. Consult variable reference (see \"Using Variables\" above)
  3. Send real email (not test) to see actual data

Issue: Email looks broken in Outlook

Causes: Outlook uses Microsoft Word rendering engine (poor CSS support)

Solutions:

  1. Use table-based layout (not flexbox/grid)
  2. Use inline CSS (not external styles)
  3. Test specifically in Outlook (use Litmus or Email on Acid)
"},{"location":"v2/user-guides/content-editor-guide/#media-library","title":"Media Library","text":"

Issue: Video won't upload

Solutions:

  1. Check file size (max 10 GB)
  2. Check file format (must be MP4, MOV, AVI, MKV, WebM, M4V, or FLV)
  3. Check internet connection (large files need stable connection)
  4. Try different browser

Issue: Metadata extraction failed

Symptoms: Duration shows \"Unknown\", quality shows \"N/A\"

Causes:

Solutions:

  1. Try re-encoding video (use HandBrake or similar)
  2. Convert to MP4 with H.264 codec (most compatible)
  3. Contact administrator (may be server configuration issue)

Issue: Video won't play on public gallery

Causes:

Solutions:

  1. Verify video is shared (Content > Media > Shared Media)
  2. Re-encode as H.264 MP4 (best browser compatibility)
  3. Check server logs (ask administrator)
"},{"location":"v2/user-guides/content-editor-guide/#related-documentation","title":"Related Documentation","text":"

Last updated: February 2026 (V2 complete)

"},{"location":"v2/user-guides/map-organizer-guide/","title":"Map Organizer Guide","text":""},{"location":"v2/user-guides/map-organizer-guide/#overview","title":"Overview","text":"

As a Map Organizer, you're responsible for managing territories, coordinating volunteers, and organizing door-to-door canvassing using Changemaker Lite's Map module. This guide will help you:

Whether you're organizing a local ward campaign or a city-wide canvass, this guide provides strategies for effective territory management.

"},{"location":"v2/user-guides/map-organizer-guide/#understanding-map-roles","title":"Understanding Map Roles","text":"

You may have one of two roles for map management:

"},{"location":"v2/user-guides/map-organizer-guide/#super_admin","title":"SUPER_ADMIN","text":""},{"location":"v2/user-guides/map-organizer-guide/#map_admin","title":"MAP_ADMIN","text":"

Role Specialization

If you only manage field operations (not campaigns), ask for MAP_ADMIN role. This keeps the interface focused on your work.

"},{"location":"v2/user-guides/map-organizer-guide/#understanding-location-data","title":"Understanding Location Data","text":""},{"location":"v2/user-guides/map-organizer-guide/#what-is-a-location","title":"What is a Location?","text":"

A location is a physical address where canvassing occurs. Each location represents:

Location data includes:

"},{"location":"v2/user-guides/map-organizer-guide/#building-vs-unit-level","title":"Building vs Unit Level","text":"

Building-level data (recommended):

Unit-level data (alternative):

Recommended Approach

Use building-level data for apartments (one record with unitCount). This reduces database size and simplifies canvassing (volunteers visit building once, not once per unit).

"},{"location":"v2/user-guides/map-organizer-guide/#data-sources","title":"Data Sources","text":"

1. CSV Import \u2014 Your own data

2. NAR Import \u2014 Canadian electoral data

3. Manual Entry \u2014 Individual addresses

"},{"location":"v2/user-guides/map-organizer-guide/#importing-locations-from-csv","title":"Importing Locations from CSV","text":""},{"location":"v2/user-guides/map-organizer-guide/#preparing-your-csv-file","title":"Preparing Your CSV File","text":"

Required columns:

Optional columns:

CSV example:

address,city,province,postalCode,buildingType,unitCount\n\"123 Main St\",\"Ottawa\",\"ON\",\"K1A 0B1\",\"RESIDENTIAL\",1\n\"456 Queen St E, Unit 5\",\"Toronto\",\"ON\",\"M5A 1T1\",\"APARTMENT\",36\n\"789 Granville St\",\"Vancouver\",\"BC\",\"V6Z 1K3\",\"RESIDENTIAL\",1\n

CSV formatting tips:

  1. Use quotes around addresses with commas
  2. Remove special characters (emoji, unusual symbols)
  3. Use UTF-8 encoding (not Windows-1252 or ASCII)
  4. One header row (first row = column names)
  5. No blank rows (delete empty rows at end)
  6. Consistent province codes (use 2-letter abbreviations)

Excel to CSV:

  1. Open your Excel file
  2. File > Save As
  3. Format: \"CSV UTF-8 (Comma delimited) (*.csv)\"
  4. Save
"},{"location":"v2/user-guides/map-organizer-guide/#importing-the-csv","title":"Importing the CSV","text":"

To import locations:

  1. Navigate to Map > Locations
  2. Click \"Import CSV\" button (top-right)
  3. Upload your CSV file (drag-drop or browse)
  4. Map CSV columns to location fields
  5. Preview imported data (first 10 rows shown)
  6. Click \"Import\"

Screenshot placeholder: CSV import dialog showing file upload area and column mapping interface

Column mapping:

The system tries to auto-detect columns, but verify:

If your CSV uses different column names (e.g., \"Street Address\" instead of \"address\"), map manually using the dropdowns.

What happens during import:

  1. System validates each row (checks required fields)
  2. Skips invalid rows (logs errors)
  3. Creates location records
  4. Geocodes addresses (if lat/lng not provided)
  5. Shows summary: X imported, Y skipped

Import limits:

"},{"location":"v2/user-guides/map-organizer-guide/#troubleshooting-import-issues","title":"Troubleshooting Import Issues","text":"

Issue: \"Invalid CSV format\"

Causes:

Solutions:

Issue: \"Missing required field\"

Causes:

Solutions:

Issue: \"Geocoding failed for X addresses\"

Causes:

Solutions:

"},{"location":"v2/user-guides/map-organizer-guide/#nar-import-canadian-electoral-data","title":"NAR Import (Canadian Electoral Data)","text":""},{"location":"v2/user-guides/map-organizer-guide/#what-is-nar-data","title":"What is NAR Data?","text":"

NAR (National Address Register) is Elections Canada's official database of all residential addresses in Canada. It includes:

Advantages:

Disadvantages:

"},{"location":"v2/user-guides/map-organizer-guide/#obtaining-nar-data","title":"Obtaining NAR Data","text":"

NAR data must be obtained from Elections Canada:

  1. Contact Elections Canada Open Data team
  2. Request latest NAR dataset (e.g., \"NAR 2025 Server\")
  3. Download Address and Location files
  4. Provide files to your system administrator

Files needed:

System administrator places files in /data directory on server.

"},{"location":"v2/user-guides/map-organizer-guide/#importing-nar-data","title":"Importing NAR Data","text":"

To import NAR data:

  1. Navigate to Map > Locations
  2. Click \"NAR Import\" button
  3. Select province (e.g., Ontario)
  4. Choose dataset (if multiple years available)
  5. Apply filters (see below)
  6. Click \"Start Import\"

Screenshot placeholder: NAR Import modal showing province selector, dataset picker, and filter options

Import filters:

Province filter (required):

City filter (optional):

Postal code filter (optional):

Cut filter (optional):

Residential only (toggle):

What happens during NAR import:

  1. System scans NAR files for selected province
  2. Joins Address and Location files on LOC_GUID (internal Elections Canada ID)
  3. Filters by city, postal code (if specified)
  4. Converts coordinates from EPSG:3347 (Lambert projection) to WGS84 (lat/lng)
  5. Creates location records
  6. Shows progress (can take several minutes for large provinces)

Import performance:

Server-Side Processing

NAR import runs on the server (not in your browser). Do not close the modal during import\u2014wait for completion message.

"},{"location":"v2/user-guides/map-organizer-guide/#nar-data-fields","title":"NAR Data Fields","text":"

NAR import populates these location fields:

"},{"location":"v2/user-guides/map-organizer-guide/#creating-and-managing-cuts","title":"Creating and Managing Cuts","text":""},{"location":"v2/user-guides/map-organizer-guide/#what-is-a-cut","title":"What is a Cut?","text":"

A cut is a geographic area used to organize canvassing. Cuts are polygons drawn on a map.

Common cut types:

Why use cuts?

"},{"location":"v2/user-guides/map-organizer-guide/#cut-best-practices","title":"Cut Best Practices","text":"

Size:

Boundaries:

Naming:

Colors:

"},{"location":"v2/user-guides/map-organizer-guide/#creating-a-cut-drawing-on-map","title":"Creating a Cut (Drawing on Map)","text":"

To create a cut:

  1. Navigate to Map > Cuts
  2. Click the \"Map Drawing\" tab
  3. Click \"Start Drawing\"
  4. Click on the map to add polygon vertices
  5. Close the polygon (click near first vertex)
  6. Fill in cut details (see form below)
  7. Click \"Save Cut\"

Screenshot placeholder: Cut drawing interface showing map with polygon being drawn

Drawing tips:

  1. Start at a corner: Begin at a distinct landmark (intersection, park corner)
  2. Follow roads: Click along roads and boundaries
  3. Use zoom: Zoom in for precision, out for overview
  4. Closing detection: System detects when you're near the first point and offers to close
  5. Undo: Click \"Undo Last Point\" if you make a mistake

Cut form fields:

Name (required):

Category (required):

Color (required):

Description (optional):

Screenshot placeholder: Cut creation form showing name, category, color picker, and description

"},{"location":"v2/user-guides/map-organizer-guide/#automatic-location-assignment","title":"Automatic Location Assignment","text":"

When you save a cut, the system automatically:

  1. Checks which locations fall inside the polygon (point-in-polygon algorithm)
  2. Assigns those locations to the cut
  3. Shows count: \"X locations assigned\"

Re-assignment:

"},{"location":"v2/user-guides/map-organizer-guide/#editing-cuts","title":"Editing Cuts","text":"

To edit a cut:

  1. Navigate to Map > Cuts
  2. Click \"Edit\" in Actions column
  3. Modify name, category, color, or description
  4. Click \"Save\"

Note: You cannot edit the polygon shape after creation. To change boundaries, delete the cut and redraw.

To delete a cut:

  1. Click \"Delete\" in Actions column
  2. Confirm deletion

What happens to locations?

"},{"location":"v2/user-guides/map-organizer-guide/#managing-locations","title":"Managing Locations","text":""},{"location":"v2/user-guides/map-organizer-guide/#viewing-and-filtering-locations","title":"Viewing and Filtering Locations","text":"

To view all locations:

  1. Navigate to Map > Locations

The locations table shows:

Filters:

Screenshot placeholder: Locations table with search bar, cut filter, and geocoded status column

"},{"location":"v2/user-guides/map-organizer-guide/#editing-a-location","title":"Editing a Location","text":"

To edit a location:

  1. Click \"Edit\" in Actions column
  2. Modify fields (see below)
  3. Click \"Save\"

Editable fields:

Address details:

Coordinates:

Metadata:

Cut assignment:

Screenshot placeholder: Edit Location modal showing address fields, map with draggable pin, and metadata fields

"},{"location":"v2/user-guides/map-organizer-guide/#manually-placing-locations-on-map","title":"Manually Placing Locations on Map","text":"

If geocoding fails, you can manually place a location:

  1. Edit the location
  2. Use the map at the bottom of the form
  3. Drag the red pin to the correct position
  4. Latitude and longitude fields update automatically
  5. Click \"Save\"

Tip: Use satellite view or street view to identify exact building location.

"},{"location":"v2/user-guides/map-organizer-guide/#bulk-operations","title":"Bulk Operations","text":"

To perform bulk actions:

  1. Select locations (checkboxes in table)
  2. Choose action from \"Bulk Actions\" dropdown:
  3. Assign to Cut: Assign selected locations to a cut
  4. Geocode: Re-geocode selected locations
  5. Delete: Delete selected locations
  6. Confirm action

Screenshot placeholder: Bulk actions dropdown with selected locations and action buttons

"},{"location":"v2/user-guides/map-organizer-guide/#deleting-locations","title":"Deleting Locations","text":"

To delete locations:

  1. Select locations in table (or filter and select all)
  2. Choose \"Delete\" from bulk actions
  3. Confirm deletion

Canvass History Preserved

Deleting a location removes the address record but preserves canvass visit data (visits are linked to coordinates, not location IDs). Historical data remains for reporting.

"},{"location":"v2/user-guides/map-organizer-guide/#geocoding-and-data-quality","title":"Geocoding and Data Quality","text":""},{"location":"v2/user-guides/map-organizer-guide/#understanding-geocoding","title":"Understanding Geocoding","text":"

Geocoding converts addresses to latitude/longitude coordinates for map display.

Why geocoding matters:

"},{"location":"v2/user-guides/map-organizer-guide/#geocoding-providers","title":"Geocoding Providers","text":"

Changemaker Lite tries multiple geocoding providers in order:

  1. Nominatim (OpenStreetMap) \u2014 Free, no API key, global coverage
  2. ArcGIS \u2014 Free tier, accurate for North America
  3. Photon \u2014 Free, Europe-focused
  4. Mapbox \u2014 Requires API key, very accurate
  5. Google Geocoding \u2014 Requires API key, most accurate
  6. LocationIQ \u2014 Requires API key, Nominatim-based

How it works:

API keys (optional, configured by admin):

Without API keys, only free providers (Nominatim, ArcGIS, Photon) are used.

"},{"location":"v2/user-guides/map-organizer-guide/#geocode-confidence-levels","title":"Geocode Confidence Levels","text":"

Each geocoded location has a confidence score (0.0 to 1.0):

Confidence affects accuracy:

"},{"location":"v2/user-guides/map-organizer-guide/#data-quality-dashboard","title":"Data Quality Dashboard","text":"

To review geocoding quality:

  1. Navigate to Map > Data Quality

The dashboard shows:

Statistics cards:

Geocoding provider breakdown:

Confidence distribution:

Action items:

Screenshot placeholder: Data Quality Dashboard showing statistics cards, provider pie chart, and confidence histogram

"},{"location":"v2/user-guides/map-organizer-guide/#improving-geocoding-quality","title":"Improving Geocoding Quality","text":"

Strategy 1: Fix Address Typos

  1. Export ungeocoded locations (CSV)
  2. Review addresses in Excel
  3. Fix typos, formatting errors
  4. Re-import corrected CSV

Common issues:

Strategy 2: Re-geocode with Better Provider

  1. Configure API keys for Mapbox or Google (ask admin)
  2. Select low-confidence locations
  3. Click \"Geocode Selected\" (bulk action)
  4. System retries with all available providers

Strategy 3: Manually Place Locations

  1. Filter locations with confidence < 0.5
  2. Edit each location
  3. Find correct position on map (use satellite view)
  4. Drag pin to correct location
  5. Save

Strategy 4: Use NAR Data (Canada Only)

NAR data includes pre-geocoded coordinates with very high accuracy. If you imported from CSV and have poor geocoding, consider switching to NAR import.

"},{"location":"v2/user-guides/map-organizer-guide/#organizing-volunteer-shifts","title":"Organizing Volunteer Shifts","text":""},{"location":"v2/user-guides/map-organizer-guide/#what-is-a-shift","title":"What is a Shift?","text":"

A shift is a scheduled volunteer canvassing session. Shifts have:

Why shifts matter:

"},{"location":"v2/user-guides/map-organizer-guide/#creating-a-shift","title":"Creating a Shift","text":"

To create a shift:

  1. Navigate to Map > Shifts
  2. Click \"Create Shift\"
  3. Fill in shift details (see below)
  4. Click \"Create\"

Shift fields:

Title (required):

Description (optional):

Start Time (required):

End Time (required):

Cut (optional but recommended):

Cut Assignment Required for Canvassing

Volunteers can only start canvass sessions for shifts assigned to a cut. Always assign a cut unless the shift is for training or other non-canvassing purposes.

Max Signups (optional):

Meeting Location (optional):

Screenshot placeholder: Create Shift form showing date/time picker, cut dropdown, capacity field, and meeting location

"},{"location":"v2/user-guides/map-organizer-guide/#managing-shift-signups","title":"Managing Shift Signups","text":"

To view shift signups:

  1. Navigate to Map > Shifts
  2. Click \"Signups\" in Actions column for a shift

The signups drawer shows:

Capacity gauge:

Signup list:

Signup sources:

  1. Public signup form (/shifts page):
  2. Anyone can sign up
  3. Creates TEMP user account automatically
  4. Sends confirmation email

  5. Admin-added:

  6. You manually add volunteers
  7. Select existing users or create new

  8. Volunteer portal:

  9. USER-role volunteers sign up themselves
  10. See My Shifts page in their portal

Screenshot placeholder: Shift Signups drawer showing capacity gauge and signup list

"},{"location":"v2/user-guides/map-organizer-guide/#adding-volunteers-to-a-shift","title":"Adding Volunteers to a Shift","text":"

To manually add a volunteer:

  1. Click \"Signups\" for the shift
  2. Click \"Add Volunteer\"
  3. Select existing user from dropdown (or click \"Create New User\")
  4. Click \"Add\"

Upgrading TEMP users to USER:

After a TEMP user attends their first shift:

  1. Open shift signups
  2. Find the TEMP user
  3. Click \"Upgrade to USER\"
  4. Confirm

This gives them full canvassing access for future shifts.

"},{"location":"v2/user-guides/map-organizer-guide/#emailing-shift-volunteers","title":"Emailing Shift Volunteers","text":"

To email all volunteers in a shift:

  1. Click \"Signups\" for the shift
  2. Click \"Email All\"
  3. Compose email:
  4. Subject
  5. Body (HTML supported)
  6. Variables: {{NAME}}, {{SHIFT_TITLE}}, {{SHIFT_START}}, {{MEETING_LOCATION}}
  7. Click \"Send\"

Common email scenarios:

Reminder (day before shift):

Subject: Reminder: Tomorrow's Canvass - {{SHIFT_TITLE}}\n\nHi {{NAME}},\n\nThis is a reminder about tomorrow's canvass:\n\nShift: {{SHIFT_TITLE}}\nTime: {{SHIFT_START}}\nMeeting Point: {{MEETING_LOCATION}}\n\nPlease arrive 10 minutes early. We'll provide walk sheets and materials.\n\nLooking forward to seeing you there!\n

Cancellation (weather, etc.):

Subject: CANCELLED: {{SHIFT_TITLE}}\n\nHi {{NAME}},\n\nUnfortunately, we need to cancel tomorrow's canvass due to severe weather.\n\nWe'll reschedule and send you a new date soon. Thank you for your understanding.\n

Follow-up (after shift):

Subject: Thank you for canvassing!\n\nHi {{NAME}},\n\nThank you for participating in {{SHIFT_TITLE}}! Your efforts made a real difference.\n\nTogether, we knocked on [X] doors and spoke with [Y] residents.\n\nSee you at the next shift!\n

Screenshot placeholder: Email Shift Volunteers modal showing subject, body editor, and variable buttons

"},{"location":"v2/user-guides/map-organizer-guide/#generating-walk-sheets","title":"Generating Walk Sheets","text":""},{"location":"v2/user-guides/map-organizer-guide/#what-is-a-walk-sheet","title":"What is a Walk Sheet?","text":"

A walk sheet is a printed list of addresses for door-to-door canvassing. It includes:

"},{"location":"v2/user-guides/map-organizer-guide/#walk-sheet-settings","title":"Walk Sheet Settings","text":"

To configure walk sheet defaults:

  1. Navigate to Map > Map Settings
  2. Scroll to \"Walk Sheet Configuration\"
  3. Set:
  4. Header Text: Organization name, campaign info
  5. Footer Text: Contact info, instructions
  6. Include QR Code: Toggle ON/OFF
  7. QR Code Size: Small, medium, large
  8. Instructions: How to use the walk sheet

Example header:

Community Action Network\nFall 2024 Canvass\nContact: organizer@example.com | (555) 123-4567\n

Example footer:

Record outcomes: NH (Not Home), R (Refused), SW (Spoke With), S1-S4 (Support Level)\nReturn completed walk sheets to the office by end of week.\n

Screenshot placeholder: Map Settings page showing walk sheet configuration section

"},{"location":"v2/user-guides/map-organizer-guide/#generating-a-walk-sheet","title":"Generating a Walk Sheet","text":"

To generate a walk sheet for a cut:

  1. Navigate to Canvass > Walk Sheet
  2. Select cut from dropdown
  3. Click \"Generate\"
  4. Review PDF preview
  5. Click \"Print\" or \"Download PDF\"

OR:

  1. Navigate to Map > Locations
  2. Filter to specific cut
  3. Click \"Walk Sheet\" button (top-right)

Walk sheet contents:

Page 1:

Subsequent pages:

Screenshot placeholder: Walk sheet PDF showing header, QR code, map, and address table

"},{"location":"v2/user-guides/map-organizer-guide/#walking-order-optimization","title":"Walking Order Optimization","text":"

Walk sheets sort addresses in walking order to minimize backtracking.

Algorithm:

  1. Start at center of cut
  2. Find nearest unvisited address
  3. Move to that address
  4. Repeat until all addresses covered

This creates an efficient route similar to the GPS route in the volunteer portal.

"},{"location":"v2/user-guides/map-organizer-guide/#using-walk-sheets-in-the-field","title":"Using Walk Sheets in the Field","text":"

Distribute to volunteers:

  1. Print one walk sheet per volunteer (or per pair, if canvassing in pairs)
  2. Bring clipboards and pens
  3. Brief volunteers on how to record outcomes

Volunteers record:

After the canvass:

  1. Collect completed walk sheets
  2. Enter data into system (or scan QR code during canvass for automatic recording)
"},{"location":"v2/user-guides/map-organizer-guide/#monitoring-canvass-progress","title":"Monitoring Canvass Progress","text":""},{"location":"v2/user-guides/map-organizer-guide/#canvass-dashboard","title":"Canvass Dashboard","text":"

To view overall canvass progress:

  1. Navigate to Canvass > Dashboard

The dashboard shows:

Statistics cards:

Activity feed:

Cut progress table:

Leaderboard:

Screenshot placeholder: Canvass Dashboard showing stats cards, activity feed, cut progress table, and leaderboard

"},{"location":"v2/user-guides/map-organizer-guide/#cut-level-progress","title":"Cut-Level Progress","text":"

To view progress for a specific cut:

  1. Navigate to Canvass > Dashboard
  2. Click cut name in cut progress table

Cut detail view shows:

Export cut data:

"},{"location":"v2/user-guides/map-organizer-guide/#session-monitoring","title":"Session Monitoring","text":"

To view active canvass sessions:

  1. Navigate to Canvass > Dashboard
  2. Scroll to \"Active Sessions\" section

Each active session shows:

Warning signs:

Actions:

"},{"location":"v2/user-guides/map-organizer-guide/#data-analysis-and-reporting","title":"Data Analysis and Reporting","text":""},{"location":"v2/user-guides/map-organizer-guide/#outcome-analysis","title":"Outcome Analysis","text":"

To understand canvassing results:

  1. Navigate to Canvass > Dashboard
  2. View Outcome Breakdown chart

Outcome categories:

Interpreting outcomes:

High NOT_HOME rate (> 60%):

High REFUSED rate (> 20%):

Low SPOKE_WITH rate (< 20%):

High WRONG_ADDRESS (> 5%):

"},{"location":"v2/user-guides/map-organizer-guide/#support-level-analysis","title":"Support Level Analysis","text":"

To understand voter sentiment:

  1. View Support Levels on Canvass Dashboard

Support level breakdown:

Targeting strategy:

For GOTV:

For persuasion:

For opposition:

"},{"location":"v2/user-guides/map-organizer-guide/#volunteer-performance","title":"Volunteer Performance","text":"

To evaluate volunteer effectiveness:

  1. View Leaderboard on Canvass Dashboard

Metrics:

Identifying top performers:

Coaching opportunities:

"},{"location":"v2/user-guides/map-organizer-guide/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/user-guides/map-organizer-guide/#geocoding-issues","title":"Geocoding Issues","text":"

Issue: Many locations ungeocoded after import

Solutions:

  1. Review ungeocoded addresses (Data Quality > Export Ungeocoded)
  2. Fix typos and re-import
  3. Configure additional geocoding API keys (Mapbox, Google)
  4. Manually place locations on map

Issue: Locations geocoded to wrong area

Symptoms: Locations appear far from where they should be

Solutions:

  1. Check confidence score (likely low confidence)
  2. Edit location and manually place on map
  3. Re-geocode with better provider (if API key available)
"},{"location":"v2/user-guides/map-organizer-guide/#cut-issues","title":"Cut Issues","text":"

Issue: Locations not assigning to cut

Symptoms: Locations inside polygon not assigned after cut creation

Solutions:

  1. Verify polygon is properly closed (check vertices)
  2. Check for very complex polygons (may hit algorithm limits)
  3. Manually assign locations using bulk action

Issue: Overlapping cuts

Symptoms: Some locations assigned to wrong cut

Cause: Multiple cuts cover the same area

Solution:

"},{"location":"v2/user-guides/map-organizer-guide/#shift-issues","title":"Shift Issues","text":"

Issue: Volunteer cannot start canvass session

Symptoms: \"No active shift found\" error

Solutions:

  1. Verify shift date is today
  2. Verify volunteer is signed up for shift
  3. Verify shift has a cut assigned (required for canvassing)
  4. Verify volunteer role is USER (not TEMP)

Issue: Shift signups not appearing

Symptoms: Public signup form doesn't show shift

Solutions:

  1. Check shift start time (past shifts don't appear)
  2. Check max signups (if full, shift is hidden)
  3. Check feature toggle (Settings > Allow Public Shift Signup must be ON)
"},{"location":"v2/user-guides/map-organizer-guide/#canvassing-issues","title":"Canvassing Issues","text":"

Issue: Walking route not updating

Symptoms: Route doesn't change after completing visits

Solutions:

  1. Route updates every 30 seconds (wait a moment)
  2. Refresh volunteer's map (pull down)
  3. Check internet connection (route calculation requires server)

Issue: Visit won't save

Symptoms: Volunteer reports \"Save Visit\" doesn't work

Solutions:

  1. Check internet connection (visits save to server)
  2. Verify outcome is selected (required field)
  3. Check for abandoned session (volunteer may need to start new session)
"},{"location":"v2/user-guides/map-organizer-guide/#related-documentation","title":"Related Documentation","text":"

Last updated: February 2026 (V2 complete)

"},{"location":"v2/user-guides/volunteer-guide/","title":"Volunteer Guide","text":""},{"location":"v2/user-guides/volunteer-guide/#overview","title":"Overview","text":"

Welcome to Changemaker Lite! As a volunteer, you'll use the volunteer portal to:

This guide will help you get started and make the most of your canvassing time.

"},{"location":"v2/user-guides/volunteer-guide/#getting-started","title":"Getting Started","text":""},{"location":"v2/user-guides/volunteer-guide/#creating-your-account","title":"Creating Your Account","text":"

There are two ways to get a volunteer account:

"},{"location":"v2/user-guides/volunteer-guide/#option-1-sign-up-for-a-shift-creates-temporary-account","title":"Option 1: Sign Up for a Shift (Creates Temporary Account)","text":"
  1. Visit the public shifts page (your organizer will send you the link)
  2. Find a shift that works for your schedule
  3. Click \"Sign Up\"
  4. Fill in:
  5. Your name
  6. Your email address
  7. Phone number (optional)
  8. Click \"Confirm Signup\"

You'll receive a confirmation email with your temporary login credentials.

Temporary Accounts

When you sign up for a shift publicly, you get a TEMP account. This gives you limited access. After your first shift, an administrator will upgrade you to a full USER account with canvassing access.

"},{"location":"v2/user-guides/volunteer-guide/#option-2-admin-creates-your-account","title":"Option 2: Admin Creates Your Account","text":"

Your organizer may create an account for you directly. You'll receive a welcome email with:

Screenshot placeholder: Shift signup form showing name, email, and phone fields

"},{"location":"v2/user-guides/volunteer-guide/#logging-in","title":"Logging In","text":"

To access the volunteer portal:

  1. Go to your organization's login page (usually https://app.yourorg.org)
  2. Enter your email address
  3. Enter your password
  4. Click \"Log In\"

After logging in, you'll be automatically redirected to the volunteer dashboard at /volunteer.

Remember Me

Check \"Remember me\" to stay logged in for 7 days. Only do this on your personal device.

Screenshot placeholder: Login page with email/password fields and \"Remember me\" checkbox

"},{"location":"v2/user-guides/volunteer-guide/#first-login-change-your-password","title":"First Login: Change Your Password","text":"

If you received a temporary password, change it immediately:

  1. After logging in, click your email in the top-right corner
  2. Select \"Change Password\"
  3. Enter your temporary password
  4. Enter new password (must meet requirements)
  5. Confirm new password
  6. Click \"Update Password\"

Password requirements:

Screenshot placeholder: Change password modal showing current/new password fields

"},{"location":"v2/user-guides/volunteer-guide/#volunteer-dashboard-overview","title":"Volunteer Dashboard Overview","text":"

Your volunteer dashboard shows:

Top Navigation:

Dashboard Cards:

Screenshot placeholder: Volunteer dashboard showing statistics cards and upcoming shifts list

"},{"location":"v2/user-guides/volunteer-guide/#viewing-your-shifts","title":"Viewing Your Shifts","text":""},{"location":"v2/user-guides/volunteer-guide/#my-shifts-page","title":"My Shifts Page","text":"

To view all your shifts:

  1. Click \"My Shifts\" in the top navigation

The shifts page shows two tabs:

"},{"location":"v2/user-guides/volunteer-guide/#upcoming-shifts","title":"Upcoming Shifts","text":"

Shows shifts you're signed up for that haven't happened yet.

Each shift card shows:

Screenshot placeholder: Upcoming shifts showing three shift cards with date, time, and location

"},{"location":"v2/user-guides/volunteer-guide/#past-shifts","title":"Past Shifts","text":"

Shows shifts you've completed or that have passed.

Each past shift shows:

Screenshot placeholder: Past shifts showing completed shift cards with visit counts

"},{"location":"v2/user-guides/volunteer-guide/#shift-details","title":"Shift Details","text":"

To view shift details:

  1. Click on a shift card
  2. View:
  3. Full description
  4. Map of the cut you'll canvass
  5. List of other volunteers (if visible)
  6. Instructions from organizer
  7. QR code to start canvassing (if you arrive early)

Screenshot placeholder: Shift detail modal showing map, description, and volunteer list

"},{"location":"v2/user-guides/volunteer-guide/#canceling-a-signup","title":"Canceling a Signup","text":"

To cancel a shift signup:

  1. Find the shift in My Shifts > Upcoming
  2. Click \"Cancel Signup\"
  3. Confirm cancellation

Cancel Early

Please cancel at least 24 hours before the shift if possible. Your organizer needs time to find a replacement.

You'll receive a confirmation email when you cancel.

"},{"location":"v2/user-guides/volunteer-guide/#canvassing","title":"Canvassing","text":""},{"location":"v2/user-guides/volunteer-guide/#starting-a-canvass-session","title":"Starting a Canvass Session","text":"

You can start canvassing in two ways:

"},{"location":"v2/user-guides/volunteer-guide/#method-1-from-dashboard-if-shift-is-today","title":"Method 1: From Dashboard (If Shift is Today)","text":"
  1. Go to Volunteer Dashboard
  2. If you have a shift today, you'll see a \"Start Canvassing\" button
  3. Click the button
  4. Select which shift you're canvassing for (if you have multiple)
  5. Click \"Start Session\"
"},{"location":"v2/user-guides/volunteer-guide/#method-2-from-my-shifts","title":"Method 2: From My Shifts","text":"
  1. Go to My Shifts
  2. Find today's shift
  3. Click \"Start Canvassing\"
"},{"location":"v2/user-guides/volunteer-guide/#method-3-scan-qr-code-walk-sheet","title":"Method 3: Scan QR Code (Walk Sheet)","text":"

If your organizer gave you a printed walk sheet:

  1. Open your phone's camera app
  2. Point at the QR code on the walk sheet
  3. Tap the notification that appears
  4. Your browser will open and start the session automatically

Screenshot placeholder: Start canvassing button on dashboard with shift selector dropdown

One Session at a Time

You can only have one active session. Finish your current session before starting a new one.

"},{"location":"v2/user-guides/volunteer-guide/#understanding-the-canvass-map","title":"Understanding the Canvass Map","text":"

When you start a session, you'll see a full-screen map with:

Map Elements:

  1. Your location (blue dot with accuracy circle)
  2. Updates as you move
  3. Accuracy circle shows GPS precision

  4. Locations to visit (house icons)

  5. Gray house: Not visited yet
  6. Yellow house: You visited, outcome recorded
  7. Red house: Refused to talk
  8. Green house: Supportive (LEVEL_1 or LEVEL_2)
  9. Blue house: Not home

  10. Walking route (purple line)

  11. Suggested path connecting unvisited locations
  12. Updates as you complete visits
  13. Follow the line for efficient canvassing

  14. Cut boundary (colored polygon)

  15. Your assigned territory
  16. Don't canvass outside this area

Screenshot placeholder: Canvass map showing blue location dot, house icons in different colors, and purple walking route

"},{"location":"v2/user-guides/volunteer-guide/#map-controls","title":"Map Controls","text":"

Top-left controls:

Bottom toolbar:

Screenshot placeholder: Map controls showing timer, visit counter, and \"Next Door\" button

"},{"location":"v2/user-guides/volunteer-guide/#following-your-walking-route","title":"Following Your Walking Route","text":"

The purple line on the map is your suggested walking route.

How the route works:

  1. Starts at your current location
  2. Connects to nearest unvisited location
  3. Then to next nearest unvisited location
  4. And so on, minimizing backtracking

To follow the route:

  1. Look at the map
  2. Walk toward the first location on the purple line
  3. Your blue dot will move as you walk
  4. When you reach a location, tap the house icon
  5. Record your visit (see next section)
  6. The route automatically updates to skip that location

Use Turn-by-Turn Navigation

For long distances, tap a location and select \"Get Directions\" to open Google Maps for turn-by-turn navigation.

Screenshot placeholder: Walking route showing path from current location through several unvisited houses

"},{"location":"v2/user-guides/volunteer-guide/#recording-visits","title":"Recording Visits","text":"

To record a visit:

  1. Knock on the door (or ring doorbell)
  2. Wait 20-30 seconds
  3. If someone answers, have your conversation
  4. After the interaction (or non-interaction), tap the house icon on the map
  5. A bottom sheet slides up with the visit recording form

Screenshot placeholder: Bottom sheet showing visit recording form with outcome buttons

"},{"location":"v2/user-guides/volunteer-guide/#visit-outcomes","title":"Visit Outcomes","text":"

You must select one of seven outcomes:

"},{"location":"v2/user-guides/volunteer-guide/#1-not_home-nobody-answered","title":"1. NOT_HOME (Nobody Answered)","text":"

When to use:

What happens:

"},{"location":"v2/user-guides/volunteer-guide/#2-refused-refused-to-talk","title":"2. REFUSED (Refused to Talk)","text":"

When to use:

What happens:

"},{"location":"v2/user-guides/volunteer-guide/#3-spoke_with-had-a-conversation","title":"3. SPOKE_WITH (Had a Conversation)","text":"

When to use:

What happens:

Most important outcome \u2014 this is your goal!

"},{"location":"v2/user-guides/volunteer-guide/#4-moved_away-resident-moved","title":"4. MOVED_AWAY (Resident Moved)","text":"

When to use:

What happens:

"},{"location":"v2/user-guides/volunteer-guide/#5-wrong_address-location-doesnt-exist","title":"5. WRONG_ADDRESS (Location Doesn't Exist)","text":"

When to use:

What happens:

"},{"location":"v2/user-guides/volunteer-guide/#6-do_not_contact-asked-not-to-be-contacted","title":"6. DO_NOT_CONTACT (Asked Not to Be Contacted)","text":"

When to use:

What happens:

Respect Privacy

Always honor \"do not contact\" requests immediately. It's legally required in many jurisdictions.

"},{"location":"v2/user-guides/volunteer-guide/#7-other-something-else","title":"7. OTHER (Something Else)","text":"

When to use:

What happens:

Screenshot placeholder: Outcome buttons showing seven options with icons

"},{"location":"v2/user-guides/volunteer-guide/#support-levels","title":"Support Levels","text":"

When you select SPOKE_WITH, you'll be asked to rate the resident's support level.

Support Level Guide:

"},{"location":"v2/user-guides/volunteer-guide/#level_1-strong-support","title":"LEVEL_1: Strong Support","text":""},{"location":"v2/user-guides/volunteer-guide/#level_2-leaning-support","title":"LEVEL_2: Leaning Support","text":""},{"location":"v2/user-guides/volunteer-guide/#level_3-undecided-neutral","title":"LEVEL_3: Undecided / Neutral","text":""},{"location":"v2/user-guides/volunteer-guide/#level_4-opposition","title":"LEVEL_4: Opposition","text":"

Be Honest

Record the support level as accurately as possible. This data helps your organizer understand the community and plan strategy.

Screenshot placeholder: Support level selector showing LEVEL_1 through LEVEL_4 with descriptions

"},{"location":"v2/user-guides/volunteer-guide/#requesting-signs","title":"Requesting Signs","text":"

If the resident is supportive (LEVEL_1 or LEVEL_2), you can mark that they want a yard sign.

To record a sign request:

  1. After selecting support level
  2. Toggle \"Wants Sign\" to ON
  3. Optionally add notes (e.g., \"Prefers small sign\", \"Needs post\")

Your organizer will see this request and arrange sign delivery.

Screenshot placeholder: Sign request toggle and notes field in visit form

"},{"location":"v2/user-guides/volunteer-guide/#taking-notes-and-photos","title":"Taking Notes and Photos","text":"

Notes field:

Use the notes field to record:

Example notes:

Photo upload (optional):

Some organizations enable photo upload. You might take photos of:

Privacy

Never take photos of people without permission. Only photograph property/signs if allowed by your organizer.

Screenshot placeholder: Notes textarea and photo upload button in visit form

"},{"location":"v2/user-guides/volunteer-guide/#saving-a-visit","title":"Saving a Visit","text":"

To save the visit:

  1. Select outcome
  2. Select support level (if spoke with resident)
  3. Add notes (optional)
  4. Toggle sign request (if applicable)
  5. Click \"Save Visit\"

The bottom sheet closes, the location icon changes color, and your visit counter increments.

Screenshot placeholder: Complete visit form with all fields filled and \"Save Visit\" button highlighted

"},{"location":"v2/user-guides/volunteer-guide/#skipping-a-location","title":"Skipping a Location","text":"

If you need to skip a location:

  1. Don't tap the house icon
  2. Walk to the next location on your route

Reasons to skip:

You can come back to skipped locations later in the session.

"},{"location":"v2/user-guides/volunteer-guide/#using-gps-navigation","title":"Using GPS Navigation","text":""},{"location":"v2/user-guides/volunteer-guide/#enabling-location-permissions","title":"Enabling Location Permissions","text":"

To allow location access:

On iPhone:

  1. When app requests location, tap \"Allow While Using App\"
  2. Or go to Settings > Safari > Location > Allow

On Android:

  1. When prompted, tap \"Allow\"
  2. Or go to Settings > Apps > Chrome > Permissions > Location > Allow

Location Required

The canvassing map requires location access to show your position and update the walking route.

Screenshot placeholder: Location permission prompt on mobile browser

"},{"location":"v2/user-guides/volunteer-guide/#improving-gps-accuracy","title":"Improving GPS Accuracy","text":"

Tips for better GPS:

  1. Enable high accuracy mode
  2. iPhone: Settings > Privacy > Location Services > System Services > Improve Location
  3. Android: Settings > Location > Google Location Accuracy > ON

  4. Ensure clear sky view

  5. GPS works best outdoors
  6. Move away from tall buildings if possible
  7. Trees and structures reduce accuracy

  8. Wait for signal

  9. When you start session, GPS may take 30-60 seconds to lock
  10. Blue circle will shrink as accuracy improves

  11. Keep phone unlocked

  12. Some browsers pause location updates when screen is locked
  13. Consider increasing screen timeout

  14. Use Wi-Fi

  15. Even if not connected, enabling Wi-Fi improves location accuracy
  16. Wi-Fi scanning helps triangulate position

Screenshot placeholder: Map showing blue location dot with large accuracy circle (poor) vs small circle (good)

"},{"location":"v2/user-guides/volunteer-guide/#next-door-button","title":"\"Next Door\" Button","text":"

The \"Next Door\" button at the bottom of the map automatically:

  1. Finds the nearest unvisited location
  2. Centers map on that location
  3. Highlights the location (pulses)

When to use it:

Screenshot placeholder: \"Next Door\" button highlighted with arrow pointing to nearest unvisited location

"},{"location":"v2/user-guides/volunteer-guide/#gps-troubleshooting","title":"GPS Troubleshooting","text":"

If GPS isn't working:

  1. Refresh the page: Pull down to refresh
  2. Check permissions: Make sure location is allowed
  3. Toggle location off/on: In phone settings
  4. Restart browser: Close and reopen
  5. Try airplane mode toggle: Turn on/off to reset radios
  6. Check battery saver: Some battery saver modes disable GPS
  7. Contact your organizer: They can manually mark your visits
"},{"location":"v2/user-guides/volunteer-guide/#ending-your-session","title":"Ending Your Session","text":""},{"location":"v2/user-guides/volunteer-guide/#finishing-canvassing","title":"Finishing Canvassing","text":"

When you're done canvassing:

  1. Open the menu (hamburger icon, top-left)
  2. Tap \"End Session\"
  3. Review your session summary:
  4. Total visits
  5. Breakdown by outcome
  6. Session duration
  7. Support levels found
  8. Tap \"Confirm End Session\"

Screenshot placeholder: End session confirmation showing session statistics

"},{"location":"v2/user-guides/volunteer-guide/#session-summary","title":"Session Summary","text":"

After ending, you'll see a summary screen with:

Your results:

What happens next:

Share Your Results

Take a screenshot of your summary to share on social media and encourage other volunteers!

Screenshot placeholder: Session summary screen showing statistics and \"Share Results\" button

"},{"location":"v2/user-guides/volunteer-guide/#abandoned-sessions","title":"Abandoned Sessions","text":"

If you forget to end your session, don't worry:

"},{"location":"v2/user-guides/volunteer-guide/#viewing-your-activity","title":"Viewing Your Activity","text":""},{"location":"v2/user-guides/volunteer-guide/#my-activity-page","title":"My Activity Page","text":"

To view your canvassing history:

  1. Click \"My Activity\" in the top navigation

The activity page shows:

Statistics cards:

Outcome breakdown chart:

Visit history table:

Screenshot placeholder: My Activity page showing statistics, pie chart, and visit history table

"},{"location":"v2/user-guides/volunteer-guide/#filtering-your-activity","title":"Filtering Your Activity","text":"

Available filters:

Screenshot placeholder: Activity filters showing date range picker and outcome dropdown

"},{"location":"v2/user-guides/volunteer-guide/#exporting-your-data","title":"Exporting Your Data","text":"

To export your activity:

  1. Go to My Activity
  2. Apply filters (optional)
  3. Click \"Export CSV\"
  4. Open the file in Excel or Google Sheets

The export includes all visible visits with full details.

"},{"location":"v2/user-guides/volunteer-guide/#my-routes","title":"My Routes","text":""},{"location":"v2/user-guides/volunteer-guide/#viewing-past-routes","title":"Viewing Past Routes","text":"

To see where you've canvassed:

  1. Click \"My Routes\" in the top navigation

Each past session shows:

Screenshot placeholder: My Routes showing map with GPS track and visited location markers

"},{"location":"v2/user-guides/volunteer-guide/#route-statistics","title":"Route Statistics","text":"

For each route, you can see:

This helps you improve your canvassing technique over time.

"},{"location":"v2/user-guides/volunteer-guide/#mobile-tips","title":"Mobile Tips","text":""},{"location":"v2/user-guides/volunteer-guide/#battery-saving","title":"Battery Saving","text":"

Canvassing uses GPS continuously, which drains battery. To conserve:

  1. Lower screen brightness: Adjust in quick settings
  2. Enable battery saver (after GPS locks): Reduces background activity
  3. Close other apps: Free up resources
  4. Bring portable charger: Essential for long sessions
  5. Use low power mode (cautiously): May reduce GPS accuracy

Expected battery life:

Screenshot placeholder: Phone battery settings showing low power mode and brightness slider

"},{"location":"v2/user-guides/volunteer-guide/#offline-considerations","title":"Offline Considerations","text":"

The canvassing app requires internet connection for:

No Offline Mode

Currently, there's no offline mode. Ensure you have cellular data or Wi-Fi before starting.

If you lose connection:

Tips:

"},{"location":"v2/user-guides/volunteer-guide/#network-connectivity","title":"Network Connectivity","text":"

Minimum requirements:

Recommended:

Data usage:

"},{"location":"v2/user-guides/volunteer-guide/#safety-privacy","title":"Safety & Privacy","text":""},{"location":"v2/user-guides/volunteer-guide/#personal-safety-tips","title":"Personal Safety Tips","text":"

Before you go:

  1. Let someone know: Tell a friend/family where you'll be canvassing
  2. Bring a buddy: Canvass in pairs if possible
  3. Charge your phone: Essential for emergencies
  4. Wear comfortable shoes: You'll be walking a lot
  5. Check the weather: Dress appropriately

While canvassing:

  1. Stay in public view: Don't enter homes or yards
  2. Trust your instincts: Skip locations that feel unsafe
  3. Avoid aggressive dogs: Use the \"skip\" function
  4. Stay hydrated: Bring water, especially in summer
  5. Take breaks: Rest every hour
  6. Be aware of traffic: Look both ways before crossing streets

If you feel unsafe:

  1. Leave the area immediately
  2. Mark the location with outcome \"OTHER\" and note the safety concern
  3. Contact your organizer
  4. Call 911 if there's an emergency

Safety First

Never prioritize completing visits over your personal safety. It's always okay to skip a location or end your session early.

Screenshot placeholder: Safety checklist infographic

"},{"location":"v2/user-guides/volunteer-guide/#privacy-of-resident-information","title":"Privacy of Resident Information","text":"

What you can do with resident data:

What you cannot do:

Legal obligations:

Data you record is used for:

Confidentiality

Treat all resident information as confidential. Violating privacy can result in legal consequences and harm the campaign.

"},{"location":"v2/user-guides/volunteer-guide/#faqs","title":"FAQs","text":""},{"location":"v2/user-guides/volunteer-guide/#account-login","title":"Account & Login","text":"

Q: I forgot my password. How do I reset it?

A: Click \"Forgot Password\" on the login page, enter your email, and check your email for reset instructions.

Q: My email says I have a TEMP account. What does that mean?

A: TEMP accounts are created when you sign up for a shift publicly. After your first shift, an admin will upgrade you to a USER account with full access.

Q: Can I change my email address?

A: Contact your organizer to change your email. You cannot change it yourself.

"},{"location":"v2/user-guides/volunteer-guide/#shifts","title":"Shifts","text":"

Q: I signed up for a shift but didn't receive a confirmation email.

A: Check your spam folder. If still not there, contact your organizer to verify your signup.

Q: Can I sign up a friend for a shift?

A: Use the public signup form (one signup per person). Or ask your organizer to create accounts for multiple people.

Q: What if I'm running late to a shift?

A: Contact your organizer as soon as possible. You can still start canvassing when you arrive.

Q: I don't see any shifts. When will more be added?

A: Your organizer creates shifts as needed. Check back regularly or ask when the next shift will be scheduled.

"},{"location":"v2/user-guides/volunteer-guide/#canvassing_1","title":"Canvassing","text":"

Q: What should I say at the door?

A: Your organizer will provide a script or talking points. Generally: 1. Introduce yourself and your organization 2. Briefly explain why you're canvassing 3. Ask if they have time to talk 4. Respect their answer (yes or no)

Q: What if someone gets angry?

A: Stay calm, polite, and respectful. Say \"I understand, thank you for your time\" and leave. Mark as REFUSED. If threatened, leave immediately and report to your organizer.

Q: Can I canvass outside my assigned cut?

A: No, stick to your assigned territory. Other volunteers may be assigned to other cuts, and visiting outside your area creates duplication.

Q: What if I make a mistake recording a visit?

A: Contact your organizer. They can edit visit records in the admin panel.

Q: The walking route seems inefficient. Can I change it?

A: The route is generated automatically. You can visit locations in any order you prefer\u2014the route is just a suggestion.

Q: What if it starts raining?

A: Your safety comes first. End your session and seek shelter. You can resume canvassing later.

"},{"location":"v2/user-guides/volunteer-guide/#technical-issues","title":"Technical Issues","text":"

Q: The map won't load.

A: 1. Check your internet connection 2. Refresh the page (pull down) 3. Try logging out and back in 4. Try a different browser 5. Contact your organizer if still not working

Q: My location is wrong on the map.

A: 1. Make sure location permissions are enabled 2. Move to an area with clear sky view 3. Wait 1-2 minutes for GPS to improve 4. Toggle airplane mode off/on to reset GPS

Q: I can't save a visit.

A: 1. Check your internet connection (visit saves to server) 2. Make sure you selected an outcome 3. Try refreshing the page 4. If offline, visit will save when connection returns

Q: The app is slow.

A: 1. Close other apps (frees up memory) 2. Restart your browser 3. Clear browser cache (Settings > Safari/Chrome > Clear Cache) 4. Update your browser to latest version

Q: I accidentally ended my session. Can I resume?

A: No, sessions cannot be resumed. Start a new session to continue canvassing.

"},{"location":"v2/user-guides/volunteer-guide/#data-privacy","title":"Data & Privacy","text":"

Q: What data do you collect about me?

A: We collect: - Your name and email (account info) - GPS location (only during canvassing sessions) - Visit records (outcomes, notes you enter) - Session statistics (time, visit count)

Q: Is my location tracked when I'm not canvassing?

A: No, location is only accessed when you have an active canvassing session. Close your browser when done to ensure no tracking.

Q: Can other volunteers see my activity?

A: Other volunteers cannot see your activity. Only administrators can view visit records and statistics.

Q: Can I delete my account?

A: Contact your organizer to request account deletion. This will remove your personal information but preserve anonymized visit records for campaign statistics.

Q: What happens to the data I collect?

A: Visit data is used for: - Campaign strategy (identifying support levels) - Volunteer coordination (tracking coverage) - Sign delivery (fulfilling requests) - Follow-up outreach (contacting supportive residents)

Data is never sold or shared with third parties.

"},{"location":"v2/user-guides/volunteer-guide/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/user-guides/volunteer-guide/#common-issues","title":"Common Issues","text":""},{"location":"v2/user-guides/volunteer-guide/#cannot-start-canvass-session","title":"Cannot Start Canvass Session","text":"

Error: \"No active shift found\"

Solution: You need a shift assigned to you for today. Check My Shifts to see if you have any upcoming shifts. If not, sign up for a shift or contact your organizer.

Error: \"Shift has no cut assigned\"

Solution: The shift you signed up for doesn't have a territory assigned. Contact your organizer to assign a cut to the shift.

Error: \"You already have an active session\"

Solution: You have an abandoned session from a previous canvass. Contact your organizer to close the old session, or wait 12 hours for automatic cleanup.

"},{"location":"v2/user-guides/volunteer-guide/#gps-not-working","title":"GPS Not Working","text":"

Symptoms: Blue location dot doesn't appear or doesn't move

Solutions:

  1. Enable location permissions:
  2. iPhone: Settings > Safari > Location Services > While Using
  3. Android: Settings > Apps > Chrome > Permissions > Location > Allow
  4. Refresh the page: Pull down to refresh
  5. Check GPS signal: Move to an area with clear sky view
  6. Restart location services: Toggle location off/on in phone settings
  7. Try a different browser: Some browsers have better GPS support
"},{"location":"v2/user-guides/volunteer-guide/#walking-route-not-updating","title":"Walking Route Not Updating","text":"

Symptoms: Purple line doesn't change after completing visits

Solutions:

  1. Refresh the map: Pull down to refresh
  2. Check internet connection: Route updates require server communication
  3. Wait 30 seconds: Updates may be delayed
  4. Manually navigate: Use \"Next Door\" button instead of following line
"},{"location":"v2/user-guides/volunteer-guide/#visit-wont-save","title":"Visit Won't Save","text":"

Symptoms: \"Save Visit\" button doesn't work or shows error

Solutions:

  1. Check required fields: Make sure you selected an outcome
  2. Check internet connection: Visits save to server (requires connection)
  3. Try again: Close bottom sheet and tap location again
  4. Refresh page: Pull down to refresh
  5. Record offline: If persistently failing, write down visit details and report to organizer later
"},{"location":"v2/user-guides/volunteer-guide/#bottom-sheet-wont-close","title":"Bottom Sheet Won't Close","text":"

Symptoms: Visit recording form stays open after saving

Solutions:

  1. Swipe down: Swipe bottom sheet downward to close
  2. Tap outside: Tap on the map area
  3. Refresh page: Pull down to refresh
"},{"location":"v2/user-guides/volunteer-guide/#getting-help","title":"Getting Help","text":"

If you have technical issues during canvassing:

  1. Try basic troubleshooting: Refresh page, check connection
  2. Continue canvassing: Use \"Next Door\" button and visual map
  3. Take notes: Write down visit details if app fails
  4. Report to organizer: After session, explain what happened

If you have questions about canvassing technique:

  1. Ask your organizer: Before the shift
  2. Consult the script: Your organizer should provide talking points
  3. Watch experienced volunteers: Learn by observing

If you have account or scheduling issues:

  1. Contact your organizer: They have admin access to fix account problems
  2. Check your email: Look for notifications about shift changes
  3. Review this guide: Many common questions are answered here
"},{"location":"v2/user-guides/volunteer-guide/#related-documentation","title":"Related Documentation","text":"

Last updated: February 2026 (V2 complete)

Need help? Contact your organizer or visit the documentation at /docs.

"},{"location":"blog/archive/2025/","title":"2025","text":""}]}