{"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":"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":"./config.sh to configure your environmentdocker compose up -d./start-production.shchangemaker.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":"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:
server.js into modular components. 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.
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.
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":"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:
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:
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:
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:
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:
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:
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":"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":"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:
You don't have to switch everything at once:
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:
These docs are preserved for existing V1 installations:
"},{"location":"v1/#build-guides","title":"Build Guides","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":"# 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:
sudo ufw allow ssh)PubkeyAuthentication yes is setWe initially tried Cloudflare Tunnels but encountered complexity with:
Tailscale is superior because:
# 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.
# 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:
100.x.x.x with actual Tailscale IPsyour-username with your actual username# 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
sudo ufw status and sudo ufw allow sshsudo systemctl status sshProblem: Permission denied (publickey)
chmod 600 ~/.ssh/authorized_keyschmod 755 ~/PubkeyAuthentication yesProblem: 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
ansible_host_key_checking=FalseProblem: Ansible command not found
sudo apt install ansible\n Problem: Connection timeouts
ping <tailscale-ip>tailscale statusProblem: Can't connect to Tailscale IP
tailscale statussudo systemctl status tailscaledsudo tailscale up[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":"become: yes only when necessaryansible_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":"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":"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)
This pack includes:
Option B: Install Individual Extension
After installation, the following should be visible:
Method A: Through VSCode
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:
Host: Friendly name for the connectionHostName: Tailscale IP addressUser: Username on the remote serverIdentityFile: Path to the SSH private keyForwardAgent: Enables SSH agent forwarding for Git operationsServerAliveInterval: Keeps connection alive (prevents timeouts)ServerAliveCountMax: Number of keepalive attempts# 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":"node1)On first connection, VSCode will:
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:
To Install:
# 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:
http://localhost:port on the local machineExample: 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:
Compare Files Across Servers:
Settings Sync:
# 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:
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:
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":"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 Use remote workspace for large projects
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 Environment separation:
# Development\nssh node1\ncd /home/<username>/dev-projects\n\n# Production\nssh node2\ncd /opt/production-apps\n 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 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":"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.
# 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 # Verify Docker Compose v2 is installed\ndocker compose version\n # 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:
.env file with secure defaultsDuring setup, you'll be prompted for:
example.com)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\":
Once services are running, access them locally:
"},{"location":"v1/build/#homepage-dashboard","title":"\ud83c\udfe0 Homepage Dashboard","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:
cloudflaredchangemaker-liteAfter successful deployment, services will be available at:
Public Services:
https://yourdomain.com - Main documentation sitehttps://listmonk.yourdomain.com - Email campaignshttps://docs.yourdomain.com - Documentation previewhttps://n8n.yourdomain.com - Automation platformhttps://db.yourdomain.com - NocoDBhttps://git.yourdomain.com - Giteahttps://map.yourdomain.com - Map viewerhttps://qr.yourdomain.com - QR generatorProtected Services (require authentication):
https://homepage.yourdomain.com - Dashboardhttps://code.yourdomain.com - Code ServerKey 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:
sudo ss -tulpn | grep LISTEN\n ./config.sh\n .env file and change conflicting portsFix 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:
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":"Navigate to the influence directory and create your environment file:
cd influence\ncp example.env .env\n Edit the .env file with your configuration:
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":"Check container status:
docker compose ps\n View logs:
docker compose logs -f app\n Access the application:
Access the admin panel at /admin.html and create your first administrator account.
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:
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 Restart the application:
docker compose restart\n GET / - Homepage with representative lookupGET /campaign/:slug - Individual campaign pageGET /api/public/campaigns - List active campaignsGET /api/representatives/by-postal/:postalCode - Find representativesPOST /api/emails/send - Send campaign emailGET /admin.html - Campaign management dashboardGET /email-test.html - Email testing interfacePOST /api/emails/preview - Preview email without sendingPOST /api/emails/test - Send test emailGET /api/test-smtp - Test SMTP connectiondocker 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":"NOCODB_API_URL and NOCODB_API_TOKEN in .env./scripts/build-nocodb.sh to ensure tables exist.env/email-test.html interface for diagnosticsdocker compose logs -f app for errorscurl http://localhost:3333/api/test-representNODE_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":"/api/healthEMAIL_TEST_MODE=false only in productionFor 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":"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:
https://your-nocodb.com/dashboard/#/nc/project-id/table-idEdit 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":"Check container status:
docker-compose ps\n View logs:
docker-compose logs -f map-viewer\n Access the application at http://localhost:3000
/admin.html for configurationdocker-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:
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.
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":"cd mkdocs\nmkdocs 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.
mkdocs/docs folder is included automatically.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:
Delete all folders EXCEPT these folders:
/blog/javascripts/hooks/assets/stylesheets/overridesReset the landing page:
index.md file and remove everything at the very top (the \"front matter\")./overrides/home.html to change the landing page.Reset the mkdocs.yml
mkdocs.yml and delete the nav section entirely. home.html.claude\nmkdocs.yml and remove the nav section to start with a blank menu. Add your own pages as you go.mkdocs serve (see above) to see changes instantly as you edit./assets, /stylesheets, /javascripts, or /overrides.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:
Zone.DNS (Read/Write)Account.Cloudflare Tunnel (Read/Write)Access (Read/Write)Automatic Configuration of Tunnel
The start-production.sh script will automatically create a tunnel and system service for Cloudflare.
cloudflared tunnel create or via the Cloudflare dashboard.# 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":"This section describes the configuration and features of the code-server environment.
"},{"location":"v1/config/coder/#accessing-code-server","title":"Accessing Code Server","text":"http://localhost:8080After 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":"bind-addr: The address and port code-server listens on (default: 127.0.0.1:8080)auth: Authentication method (default: password)password: The login password (see above)cert: Whether to use HTTPS (default: false)The code-server environment includes:
@anthropic-ai/claude-code) globally installedpython3-pip, python3-venv, python3-full, pipxCairoSVG, Pillow, libcairo2-dev, libfreetype6-dev, libjpeg-dev, libpng-dev, libwebp-dev, libtiff5-dev, libopenjp2-7-dev, liblcms2-devweasyprint, fonts-robotobuild-essential, pkg-config, python3-dev, zlib1g-dev/home/coder/.venv/mkdocsrun-mkdocs for running MkDocs commands easilyThe 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.
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.
The code-server environment provides robust code navigation and editing features, including:
Code-server includes features to support collaboration:
When using code-server, consider the following security aspects:
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:
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":"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":"The setup process involves several steps that must be completed in order:
.env file with your NocoDB details.env fileToken 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":"NOCODB_API_URL: Your NocoDB instance API URL (usually ends with /api/v1)NOCODB_API_TOKEN: The token you created in Step 1SESSION_SECRET: Generate a secure random string for session encryptionDEFAULT_LAT/LNG/ZOOM: Default map center and zoom levelBOUND_*: Map boundaries to restrict where users can add pointsCOOKIE_DOMAIN: Your domain for cookie securityALLOWED_ORIGINS: Comma-separated list of allowed origins for CORSThe 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:
Geo-Location (Geo-Data): Format \"latitude;longitude\"latitude (Decimal): Precision 10, Scale 8longitude (Decimal): Precision 11, Scale 8First Name (Single Line Text): Person's first nameLast Name (Single Line Text): Person's last nameEmail (Email): Email addressPhone (Single Line Text): Phone numberUnit Number (Single Line Text): Unit or apartment numberAddress (Single Line Text): Street addressSupport Level (Single Select): Options: \"1\", \"2\", \"3\", \"4\"Sign (Checkbox): Has campaign signSign Size (Single Select): Options: \"Regular\", \"Large\", \"Unsure\"Notes (Long Text): Additional details and commentsUser authentication table:
Email (Email): User email address (Primary)Name (Single Line Text): User display nameAdmin (Checkbox): Admin privilegesAdmin configuration table:
key (Single Line Text): Setting identifiertitle (Single Line Text): Display namevalue (Long Text): Setting valueGeo-Location (Text): Format \"latitude;longitude\"latitude (Decimal): Precision 10, Scale 8longitude (Decimal): Precision 11, Scale 8zoom (Number): Map zoom levelcategory (Single Select): Setting categoryupdated_by (Single Line Text): Last updater emailupdated_at (DateTime): Last update timeqr_code_1_image (Attachment): QR code 1 imageqr_code_2_image (Attachment): QR code 2 imageqr_code_3_image (Attachment): QR code 3 imageThe 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:
https://your-nocodb.com/dashboard/#/nc/project-id/table-idYou need URLs for: - Locations table \u2192 NOCODB_VIEW_URL - Login table \u2192 NOCODB_LOGIN_SHEET - Settings table \u2192 NOCODB_SETTINGS_SHEET
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":"Check that the container is running:
docker-compose ps\n Check the logs:
docker-compose logs -f map-viewer\n Access the application at http://localhost:3000 (or your configured domain)
/admin.htmlGET /api/locations - Fetch all locations (requires auth)POST /api/locations - Create new location (requires auth)GET /api/locations/:id - Get single location (requires auth)PUT /api/locations/:id - Update location (requires auth)DELETE /api/locations/:id - Delete location (requires auth)GET /api/config/start-location - Get map start locationGET /health - Health checkPOST /api/auth/login - User loginGET /api/auth/check - Check authentication statusPOST /api/auth/logout - User logoutGET /api/admin/start-location - Get start location with source infoPOST /api/admin/start-location - Update map start locationGET /api/admin/walk-sheet-config - Get walk sheet configurationPOST /api/admin/walk-sheet-config - Save walk sheet configurationGeo-Location, latitude, longitude)NOCODB_VIEW_URL is correctNOCODB_LOGIN_SHEET URL is correctNOCODB_API_URL and NOCODB_API_TOKEN are correctFor 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":"ALLOWED_ORIGINSCOOKIE_DOMAIN properlySESSION_SECRET# 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":".env file securelyFor 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:
Login and Git Code Buttons: Custom styles for .login-button and .git-code-button to create visually distinct, modern buttons with hover effects.
Code Block Improvements: Forces code blocks to wrap text (white-space: pre-wrap) and ensures inline code and tables with code display correctly on all devices.
GitHub Widget Styles: Styles for .github-widget and its subcomponents, including:
Dark mode adjustments.
Gitea Widget Styles: Similar to GitHub widget, but with Gitea branding (green accents). Includes .gitea-widget, .gitea-widget-container, and related classes for header, stats, description, footer, loading, and error states.
Responsive Design: Media queries ensure widgets and tables look good on mobile devices.
hooks/repo_widget_hook.py)","text":"docs/assets/repo-data/.serve mode).lyqht-mini-qr.json).javascripts/github-widget.js)","text":"javascripts/gitea-widget.js)","text":"mkdocs.yml)","text":"Key features and plugins enabled:
Material Theme: Modern, responsive UI with dark/light mode toggle, custom fonts, and accent colors.
Navigation Enhancements:
Table of contents with permalinks.
Content Features:
Admonitions, tabbed content, task lists, and emoji support.
Plugins:
Tags: Tagging for content organization.
Custom Hooks:
repo_widget_hook.py for repository widget data.
Extra CSS/JS:
Custom styles and scripts for widgets and homepage.
Extra Configuration:
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":"https://yoursite.com or http://localhost:3000).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":"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
Port: 9000 | Self-hosted newsletter and mailing list manager
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
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
Port: 8090 | No-code database platform
Port: 3010 | Modern dashboard for all services
Port: 3030 | Self-hosted Git service
Port: 8089 | Simple QR code generator service
Port: 3000 | Canvassing and community organizing application
\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":"http://localhost:8888/home/coder/mkdocs/DOCKER_USER: The user to run code-server as (default: coder)DEFAULT_WORKSPACE: Default workspace directoryUSER_ID: User ID for file permissionsGROUP_ID: Group ID for file permissions./configs/code-server/.config: VS Code configuration./configs/code-server/.local: Local data./mkdocs: Main workspace directoryhttp://localhost:8888/home/coder/mkdocs/ workspaceConsider installing these extensions for better documentation work:
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":"${GITEA_WEB_PORT:-3030} (default: 3030)${GITEA_SSH_PORT:-2222} (default: 2222)http://localhost:${GITEA_WEB_PORT:-3030}/data/giteaGITEA__database__DB_TYPE: Database type (e.g., sqlite3, mysql, postgres)GITEA__database__HOST: Database host (default: ${GITEA_DB_HOST:-gitea-db:3306})GITEA__database__NAME: Database name (default: ${GITEA_DB_NAME:-gitea})GITEA__database__USER: Database user (default: ${GITEA_DB_USER:-gitea})GITEA__database__PASSWD: Database password (from .env)GITEA__server__ROOT_URL: Root URL (e.g., ${GITEA_ROOT_URL})GITEA__server__HTTP_PORT: Web port (default: 3000 inside container)GITEA__server__DOMAIN: Domain (e.g., ${GITEA_DOMAIN})gitea_data:/data: Gitea configuration and data/etc/timezone:/etc/timezone:ro/etc/localtime:/etc/localtime:rohttp://localhost:${GITEA_WEB_PORT:-3030}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":"http://localhost:3010HOMEPAGE_PORT: External port mapping (default: 3010)PUID: User ID for file permissions (default: 1000)PGID: Group ID for file permissions (default: 1000)TZ: Timezone setting (default: Etc/UTC)HOMEPAGE_ALLOWED_HOSTS: Allowed hosts for the dashboardHomepage uses YAML configuration files located in ./configs/homepage/:
settings.yaml: Global settings and theme configurationservices.yaml: Service definitions and widgetsbookmarks.yaml: Bookmark categories and linkswidgets.yaml: Dashboard widgets configurationdocker.yaml: Docker integration settings./configs/homepage:/app/config: Configuration files./assets/icons:/app/public/icons: Custom service icons./assets/images:/app/public/images: Background images and assets/var/run/docker.sock:/var/run/docker.sock: Docker socket for container monitoringHomepage is pre-configured with all Changemaker Lite services:
"},{"location":"v1/services/homepage/#essential-tools","title":"Essential Tools","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:
Homepage monitors Docker containers automatically when configured:
/var/run/docker.sock)docker.yamlservices.yamlConfiguration 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:
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":"http://localhost:9000LISTMONK_ADMIN_USER environment variableLISTMONK_ADMIN_PASSWORD environment variableLISTMONK_ADMIN_USER: Admin usernameLISTMONK_ADMIN_PASSWORD: Admin passwordPOSTGRES_USER: Database usernamePOSTGRES_PASSWORD: Database passwordPOSTGRES_DB: Database nameListmonk uses PostgreSQL as its backend database. The database is automatically configured through the docker-compose setup.
"},{"location":"v1/services/listmonk/#uploads","title":"Uploads","text":"./assets/uploadshttp://localhost:9000For 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":"${MAP_PORT:-3000} (default: 3000)http://localhost:${MAP_PORT:-3000}/app/public/All configuration is done via environment variables:
Variable Description DefaultNOCODB_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":"./map/app/public: Map public assetshttp://localhost:${MAP_PORT:-3000}geodata (Text): Format \"latitude;longitude\" latitude (Decimal): Precision 10, Scale 8longitude (Decimal): Precision 11, Scale 8First Name (Text): Person's first nameLast Name (Text): Person's last name Email (Email): Contact email addressUnit Number (Text): Apartment/unit numberSupport Level (Single Select): Address (Text): Full street addressSign (Checkbox): Has campaign sign (true/false)Sign Size (Single Select): Small, Medium, LargeGeo-Location (Text): Formatted as \"latitude;longitude\"GET /api/locations - Fetch all locationsPOST /api/locations - Create new locationGET /api/locations/:id - Get single locationPUT /api/locations/:id - Update locationDELETE /api/locations/:id - Delete locationGET /health - Health checkSimple 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":"${MINI_QR_PORT:-8089} (default: 8089)http://localhost:${MINI_QR_PORT:-8089}QR_DEFAULT_SIZE: Default size of generated QR codesQR_IMAGE_FORMAT: Image format (e.g., png, svg)./configs/mini-qr: QR code service configurationhttp://localhost:${MINI_QR_PORT:-8089}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":"http://localhost:4000Configuration is managed through mkdocs.yml in the project root.
./mkdocs: Documentation source files./assets/images: Shared images directorySITE_URL: Base domain for the siteUSER_ID: User ID for file permissionsGROUP_ID: Group ID for file permissionsmkdocs/\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":"# 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.
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.
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":"http://localhost:5678N8N_DEFAULT_USER_EMAILN8N_DEFAULT_USER_PASSWORDN8N_HOST: Hostname for n8n (default: n8n.${DOMAIN})N8N_PORT: Internal port (5678)N8N_PROTOCOL: Protocol for webhooks (https)NODE_ENV: Environment (production)WEBHOOK_URL: Base URL for webhooksGENERIC_TIMEZONE: Timezone settingN8N_ENCRYPTION_KEY: Encryption key for credentialsN8N_USER_MANAGEMENT_DISABLED: Enable/disable user managementN8N_DEFAULT_USER_EMAIL: Default admin emailN8N_DEFAULT_USER_PASSWORD: Default admin passwordn8n_data: Persistent data storage./local-files: Local file access for workflowshttp://localhost:5678Webhook \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":"n8n can integrate with all services in your Changemaker Lite setup:
# 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:
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":"http://localhost:8090root_db instance)NOCODB_PORT: External port mapping (default: 8090)NC_DB: Database connection string for PostgreSQL backendNocoDB uses a dedicated PostgreSQL instance (root_db) with the following configuration:
root_dbpostgrespasswordroot_db (internal container name)nc_data: Application data and configuration storagedb_data: PostgreSQL database fileshttp://localhost:8090NocoDB can integrate well with other Changemaker Lite services:
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.
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:
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":"listmonk-db (internal container name)POSTGRES_DB environment variablePOSTGRES_USER environment variablePOSTGRES_PASSWORD environment variablePOSTGRES_USER: Database usernamePOSTGRES_PASSWORD: Database password POSTGRES_DB: Database nameThe 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.
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.
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
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":"http://localhost:4001/config/www (mounted from ./mkdocs/site)PUID: User ID for file permissions (default: 1000)PGID: Group ID for file permissions (default: 1000)TZ: Timezone setting (default: Etc/UTC)./mkdocs/site:/config/www: Static site filesdocker exec mkdocs-changemaker mkdocs buildhttp://localhost:4001./mkdocs/site/ will be served staticallymkdocs/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":"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
./mkdocs/site/PUID and PGID settings# 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":"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:
/p/:slugLearn 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":"\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:
Changemaker Lite is open source. We welcome contributions! See the Contributing Guide for:
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":"POST /api/auth/register - User registrationPOST /api/auth/login - User loginPOST /api/auth/refresh - Refresh access tokenPOST /api/auth/logout - User logoutGET /api/auth/me - Get current userGET /api/users - List usersPOST /api/users - Create userGET /api/users/:id - Get userPATCH /api/users/:id - Update userDELETE /api/users/:id - Delete userGET /api/campaigns - List campaignsPOST /api/campaigns - Create campaignGET /api/campaigns/:id - Get campaignPATCH /api/campaigns/:id - Update campaignDELETE /api/campaigns/:id - Delete campaignGET /api/campaigns/public - List public campaignsPOST /api/campaigns/:id/send-email - Send campaign emailGET /api/locations - List locationsPOST /api/locations - Create locationGET /api/locations/:id - Get locationPATCH /api/locations/:id - Update locationDELETE /api/locations/:id - Delete locationPOST /api/locations/import - CSV importGET /api/locations/export - CSV exportPOST /api/locations/geocode - Bulk geocodeGET /api/cuts - List cutsPOST /api/cuts - Create cutGET /api/shifts - List shiftsPOST /api/shifts - Create shiftGET /api/canvass/session - Get active sessionPOST /api/canvass/session/start - Start sessionPOST /api/canvass/visit - Record visitGET /api/pages - List pagesPOST /api/pages - Create pageGET /api/pages/public/:slug - Get published pageGET /api/email-templates - List templatesPOST /api/email-templates - Create templateGET /media-api/videos - List videosPOST /media-api/upload - Upload videoGET /media-api/public/videos - List public videosPOST /media-api/reactions - Add reactionAll 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":"/api/auth/loginReturns: accessToken (15min) + refreshToken (7 days)
Access Protected Resource - Include token in header
Token verified by authenticate middleware
Refresh Token - POST /api/auth/refresh
refreshTokenReturns: New accessToken + refreshToken
Logout - POST /api/auth/logout
Endpoints are protected by role requirements:
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":"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":"OpenAPI/Swagger documentation is planned for future releases. This will provide:
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":"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":"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)
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)
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:
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:
Auto-provisioned: Dashboards in /configs/grafana/
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":"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":"Learn more \u2192
"},{"location":"v2/architecture/#scalability-considerations","title":"Scalability Considerations","text":""},{"location":"v2/architecture/#horizontal-scaling","title":"Horizontal Scaling","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:
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
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:
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":"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:
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:
Microservice preparation without full microservices complexity:
api/src/server.ts vs api/src/media-server.ts)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:
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:
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)
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
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).
# 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 120MBLarge Upload (1GB file):
Framework Upload Time Memory Peak CPU Usage Express 45s 450MB 85% Fastify 38s 280MB 60%Real-World Usage:
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":"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:
http://localhost:4000/api/metricshttp://localhost:4100/metricsCustom 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.
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":"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":"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":"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:
authenticate (middleware/auth.ts)
req.user object with user ID, email, and rolerouter.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)
requestLogger (middleware/logger.ts)
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":"auth.schemas.tsREDIS_PASSWORD environment variableTypical middleware chain for protected routes:
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:
*.schemas.ts) - Zod validation schemas for requests/responses*.service.ts) - Business logic and database operations*.routes.ts) - Express router definitions with middlewareModules may split routes into admin and public variants (e.g., campaigns.routes.ts and campaigns-public.routes.ts).
/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":"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:
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:
401 Unauthorized: Invalid email or password (prevents user enumeration)403 Forbidden: Account is suspended/banned or expired429 Too Many Requests: Rate limit exceededImplementation:
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:
lastLoginAt timestampCreate 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:
409 Conflict: Email already registered400 Bad Request: Password doesn't meet complexity requirements429 Too Many Requests: Rate limit exceededPassword 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:
USER server-side (not user-controllable)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:
401 Unauthorized: Invalid, expired, or not found refresh tokenToken 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:
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:
deleteMany (safe if token doesn't exist)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:
401 Unauthorized: Missing, invalid, or expired access token401 Unauthorized: User not found (prevents user enumeration - same code as invalid token)Security Note:
Returns 401 instead of 404 when user not found to prevent user enumeration.
Purpose: Authenticate user and generate token pair.
Flow:
lastLoginAt timestampError 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:
USER roleImplementation:
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:
JWT_REFRESH_SECRETPurpose: Create short-lived JWT for API authentication.
Token Payload:
interface TokenPayload {\n id: string;\n email: string;\n role: UserRole;\n}\n Configuration:
JWT_ACCESS_SECRET environment variableJWT_ACCESS_EXPIRY (default: 15m)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:
JWT_REFRESH_SECRET (must differ from access secret)JWT_REFRESH_EXPIRY (default: 7d)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":"// 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":"/api/auth/me returns 401 (not 404) when user not foundselect or destructuring)email prevents duplicatesThe 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:
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
/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 statusExample 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:
\"Climate Action NOW!\" \u2192 climate-action-now\"Email Your MP: Support Bill C-12\" \u2192 email-your-mp-support-bill-c-12\"Climate Action Now\" (2nd with same title) \u2192 climate-action-now-2Update 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:
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:
slug (string): Campaign slugExample 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:
404 Not Found: Campaign not found or not ACTIVEList 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":"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:
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 \u2014 Physical building (lat/lng, address, buildingNotes)Address \u2014 Individual unit within building (unitNumber, firstName, lastName, supportLevel, etc.)CanvassVisit \u2014 Links to Address (not Location) for per-unit tracking/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
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:
409 Conflict: User already has active session404 Not Found: Cut not foundEnd active canvass session.
Path Parameters:
id (string): Session IDResponse (200 OK):
Returns updated session with status: COMPLETED and endedAt timestamp.
Post-Processing:
Validation:
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:
status: CONFIRMED)cutId not null)Get locations within cut for canvassing with visit annotations.
Path Parameters:
cutId (string): Cut IDQuery Parameters:
minLat, maxLat, minLng, maxLng (optional): Bounding box for visible map areaExample 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:
Visit Annotations:
lastVisit \u2014 Most recent visit to this address (any volunteer)isMyVisit \u2014 True if authenticated user made last visitGet optimized walking route for cut.
Path Parameters:
cutId (string): Cut IDQuery Parameters:
excludeVisited (boolean, default: false): Exclude already-visited addressesstartLatitude (number, optional): Starting position latitudestartLongitude (number, optional): Starting position longitudeExample 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:
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:
addressId (required): Address ID (unit within building)outcome (required): Visit outcome enumsupportLevel (optional): Support level identified during visitsignRequested (optional, default: false): Lawn sign requestedsignSize (optional): Sign size if requestednotes (optional): Visit notesdurationSeconds (optional): Time spent at doorsessionId (optional): Active canvass session IDshiftId (optional): Associated shift IDupdateLocation (optional, default: true): Update address record with visit dataResponse (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:
cm_canvass_visits_total counter with outcome labelRecord 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:
Response (201 Created):
{\n \"created\": 8,\n \"skipped\": 2,\n \"locationId\": \"clxLocation456\"\n}\n Use Cases:
Update location with role-gated field restrictions.
Path Parameters:
id (string): Address IDRequest 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:
page (default: 1): Page numberlimit (default: 20, max: 100): Results per pagecutId (optional): Filter by cutuserId (optional): Filter by volunteeroutcome (optional): Filter by outcomeExample 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):
Rate Limiting:
Abandoned Session Cleanup:
Walking Route Algorithm:
excludeVisited=trueCut Completion Calculation:
distinct: ['addressId'] to count unique addressesCut.completionPercentage fieldCause: Volunteer forgot to end previous session
Solution:
Cause: Recording visits too quickly (>30/min)
Solution:
Cause: excludeVisited=true filters out already-visited addresses
Solution:
excludeVisited=false to see all addresseslastVisit field)Cause: Completion calculated on session end, not per-visit
Solution:
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:
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:
CREATED \u2014 Location created (manual or import)UPDATED \u2014 Field changedGEOCODED \u2014 Address geocoded (auto or bulk geocoding)MOVED_ON_MAP \u2014 Lat/lng changed via map dragDELETED \u2014 Location deleted/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
/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:
total \u2014 Total location countsupportLevels \u2014 Breakdown by support levelsigns \u2014 Locations with sign=truegeocoded \u2014 Locations with lat/lngungeocoded \u2014 Locations without lat/lngconfidence.high \u2014 Geocode confidence \u2265 85confidence.medium \u2014 Geocode confidence 60-84confidence.low \u2014 Geocode confidence < 60confidence.none \u2014 No geocode confidence (0 or null)confidence.average \u2014 Average geocode confidence (excludes 0/null)providers \u2014 Breakdown by geocode providerCreate 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).
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:
file (required): CSV file (max 10MB)Supported Column Names (Case-Insensitive):
Field Column Names addressaddress, 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:
total \u2014 Total rows in CSVsuccess \u2014 Successfully created locationswarnings \u2014 Created but geocoding failed (no lat/lng)failed \u2014 Failed to create (validation errors)errors \u2014 First 50 error messages (row numbers 1-indexed)Geocoding:
latitude/longitude columns: uses provided coordinatesBulk import NAR (National Address Register) or standard CSV with advanced filtering.
Multipart Form Data:
file (required): CSV file (max 100MB)format (required): nar or standardfilterType (optional): none, cut, mapArea, city, provincecutId (optional): Cut ID for filterType=cutfilterCity (optional): City name for filterType=cityfilterProvince (optional): Province code for filterType=province (e.g., ON, BC)residentialOnly (optional, default: false): Skip non-residential buildings (NAR only)deduplicateRadius (optional, default: 5): Coordinate deduplication radius in metersskipGeocoding (optional, default: true): Skip geocoding (NAR files have coordinates)batchSize (optional, default: 1000): Database batch insert sizeRequest 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):
CIVIC_NO, CIVIC_NO_SUFFIX, OFFICIAL_STREET_NAME, OFFICIAL_STREET_TYPE, OFFICIAL_STREET_DIR, APT_NO_LABEL, BG_X, BG_Y, MAIL_MUN_NAME, MAIL_PROV_ABVN, MAIL_POSTAL_CODE, FED_ENG_NAME, BU_USEBG_LATITUDE, BG_LONGITUDE (WGS84), LOC_GUIDBG_X/BG_Y \u2014 EPSG:3347 Lambert Conformal Conic (converted to WGS84)BG_LATITUDE/BG_LONGITUDE \u2014 WGS84 (used directly)Legacy NAR Format (Backward Compatible):
STR_NBR, STR_NME, STR_TYP, STR_DIR, LAT, LNG, MUN_NME, PRV_NMEAuto-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:
filterType=cut):Uses point-in-polygon ray-casting algorithm
Map Area Filter (filterType=mapArea):
Calculates bounding box from MapSettings (center, zoom)
City Filter (filterType=city):
Imports locations matching city name (case-insensitive)
Province Filter (filterType=province):
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:
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 longitudeExample 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.
Get location edit history with audit trail.
Query Parameters:
page (optional, default: 1): Page numberlimit (optional, default: 20): Results per pageExample 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:
CREATED \u2014 Location createdUPDATED \u2014 Field changed (address, name, email, etc.)GEOCODED \u2014 Auto-geocoded (address \u2192 lat/lng)MOVED_ON_MAP \u2014 Coordinates changed via map dragDELETED \u2014 Location deleted (orphaned history records)Get locations for public map (PII-filtered).
Query Parameters:
minLat, maxLat, minLng, maxLng (optional): Bounding boxExample 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:
id, latitude, longitude, supportLevel, sign, signSize, unitNumber, addressfirstName, lastName, email, phone, notes, buildingNotes, geocodeConfidence, geocodeProvider, createdByUserId, postalCode, province, federalDistrict, buildingUseCreate 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:
csv-parse libraryBulk 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:
@@index([latitude, longitude]) \u2014 Fast map bounds queries@@index([supportLevel]) \u2014 Fast filtering by support level@@index([sign]) \u2014 Fast sign filtering@@index([geocodeConfidence]) \u2014 Fast confidence filteringSafety Limits:
Geocoding:
Cause: CSV not UTF-8 encoded or has malformed rows
Solution:
Cause: Cut/city/province filter doesn't match any records
Solution:
Cause: Incomplete addresses or geocoding provider limitations
Solution:
Cause: File too large or too many locations to geocode
Solution:
skipGeocoding=true for NAR imports (coordinates included)batchSize parameter (1000 \u2192 2000)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:
ENABLE_MEDIA_FEATURES=true (opt-in)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:
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:
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:
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 Labellike \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:
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:
compilation \u2014 Multi-video compilationscan, public_scan \u2014 Video library scanningorganize, organize_studio \u2014 Automatic organizationreencode_streaming \u2014 Transcode for web streamingcompile_random, compile_quad, compile_quad_horizontal, etc. \u2014 Compilation variantsgenerate_gif, fetch, digest, clip_generate, highlight_generate \u2014 Content generationtag_generation, scene_extract, clip_extract_only, auto_organize_publish \u2014 AI-powered tasksResource Categories:
gpu_ai \u2014 AI/ML tasks (scene detection, tagging, etc.) \u2014 High VRAMgpu_encode \u2014 Video encoding/transcoding \u2014 Medium VRAMcpu \u2014 General processing \u2014 No GPU requiredexport 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:
/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 enumrecent 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:
id (number): Media IDExample 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:
id (number): Media IDRequest Body:
{\n \"sessionId\": \"sess_abc123def456\"\n}\n Response (200 OK):
{\n \"success\": true,\n \"upvoted\": true,\n \"upvoteCount\": 90\n}\n Behavior:
publicMedia.upvoteCount atomicallyDuplicate 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:
id (number): Media IDQuery Parameters:
sessionId (string): Session IDResponse (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":"admin/src/pages/media/LibraryPage.tsx)Bulk operations (lock, unlock, delete)
SharedMediaPage (admin/src/pages/media/SharedMediaPage.tsx)
Engagement metrics display
MediaJobsPage (admin/src/pages/media/MediaJobsPage.tsx)
admin/src/pages/public/MediaGalleryPage.tsx)View count display
MediaViewerPage (admin/src/pages/public/MediaViewerPage.tsx)
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:
sql\\${publicMedia.viewCount} + 1``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:
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:
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:
ENABLE_MEDIA_FEATURES=true in .envlsof -i :4100Problem:
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:
Missing sessionId:
{ \"error\": \"sessionId is required\" }\n Media not found:
{ \"error\": \"Media not found\" }\n Locked media:
{ \"error\": \"Media is locked\" }\n Solution:
crypto.randomUUID() or nanoid()public_media tableisLocked statusProblem:
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:
userId matches authenticated usermediaId matches video IDreactionType is valid emoji typeCommon Issues:
Check JWT token in Authorization header
Invalid reaction type:
{ \"error\": \"Invalid reaction type\" }\n Video not found:
{ \"error\": \"Video not found\" }\n Solution:
like, love, laugh, wow, sad, angryvideos table (not just public_media)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:
Verify ENABLE_MEDIA_FEATURES=true
Resource exhaustion:
Check vramRequired vs available VRAM
Pipeline blocking:
Solution:
npm run worker:media or check Docker ComposeThe 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:
mkdocs/overrides/ directory.md stub files with front matter for MkDocs Material/p/:slug route.., encoded sequences)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:
blocks \u2014 GrapesJS JSON state (saved on Ctrl+S in editor)htmlOutput \u2014 Rendered HTML (generated by GrapesJS or manually entered in CODE mode)cssOutput \u2014 Extracted CSS (from GrapesJS styles or manual entry)mkdocsPath \u2014 Relative path in mkdocs/overrides/ (e.g., landing-page.html)mkdocsStubPath \u2014 Relative path to .md stub (e.g., landing-page.md)mkdocsExportMode \u2014 THEMED (Jinja2) or STANDALONE (full HTML)mkdocsSkipExport \u2014 Skip MkDocs export (for internal pages only accessible via /p/:slug)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:
\"Landing Page\" \u2192 landing-page\"About Us \u2014 Contact Info\" \u2192 about-us-contact-info\"Landing Page\" (duplicate) \u2192 landing-page-2/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
/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:
id (string): Landing page IDExample 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:
404 Not Found: Page not foundCreate 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:
slug \u2014 Generated from title (collision-safe)mkdocsPath \u2014 Defaults to ${slug}.html if not providedValidation:
title is requiredmkdocsPath must end with .htmlmkdocsPath must not contain path traversal sequences (.., null bytes, encoded traversal)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:
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 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 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:
id (string): Landing page IDResponse (204 No Content):
No response body.
Side Effects:
mkdocs/overrides/{mkdocsPath})mkdocs/docs/{mkdocsStubPath})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:
Scan mkdocs/overrides/ directory for .html files:
const files = await scanOverrideFiles(MKDOCS_OVERRIDES);\n// Returns: [{ relativePath: 'foo.html', fullPath: '/full/path/foo.html' }, ...]\n 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 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 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:
.html file directly in mkdocs/overrides/, then syncs to database.md stub filesValidate 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:
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 Check override HTML exists:
const overridePath = path.join(MKDOCS_OVERRIDES, page.mkdocsPath);\nawait fs.access(overridePath); // Throws if missing\n Check .md stub exists:
const expectedStubPath = page.mkdocsStubPath || deriveStubPath(page.mkdocsPath);\nconst stubExists = await stubExistsOnDisk(expectedStubPath);\n 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:
List blocks with optional category filter.
Query Parameters:
Parameter Type Required Description category string No Filter by categoryExample 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.
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:
slug (string): Landing page slugExample 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:
published === trueError Responses:
404 Not Found: Page not found or not publishedList 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:
published === true && mkdocsSkipExport === false && mkdocsPath && htmlOutputpublished === false || mkdocsSkipExport === trueSync 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:
mkdocs/overrides/ for .html filesValidate 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:
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:
template \u2014 Override filename (relative to custom_dir/overrides)hide \u2014 Hide Material theme elements (navigation, toc)title \u2014 Page title (SEO)description \u2014 Page description (SEO)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:
\\0)..)/etc/passwd)%2e%2e/, %2E%2E/).html)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:
editorMode: VISUALblocks: {}mkdocsExportMode: THEMEDmkdocsHideNav: truemkdocsHideToc: truemkdocsSkipExport: falsepublished: falseexport 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:
/p/:slughtmlOutput with cssOutputseoTitle, seoDescription, seoImageMkDocs 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:
published === false)mkdocsSkipExport === true)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:
Problem:
Page is published but doesn't appear on MkDocs site.
Diagnosis:
Check override file exists:
ls mkdocs/overrides/about-us.html\n Check stub file exists:
ls mkdocs/docs/about-us.md\n 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 docker compose logs -f mkdocs\nSolutions:
Missing files: Run validate endpoint to repair:
curl -X POST -H \"Authorization: Bearer <token>\" \\\n http://api.cmlite.org/api/pages/validate\n Wrong template path: Front matter template: value is relative to template search paths. Use filename only.
MkDocs rebuild: Restart MkDocs container:
docker compose restart mkdocs\n 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 ...
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:
htmlOutput from disk.Solution:
Option 1: Edit file on disk directly:
vim mkdocs/overrides/my-page.html\n# Then sync\ncurl -X POST -H \"Authorization: Bearer <token>\" http://api.cmlite.org/api/pages/sync\n Option 2: Change editorMode to VISUAL if you want database to be source of truth:
UPDATE landing_pages SET \"editorMode\" = 'VISUAL' WHERE slug = 'my-page';\n 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":"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:
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:
postalCode \u2014 Canadian postal code (e.g., \"M5H 2N2\")name \u2014 Representative's full nameemail \u2014 Contact email addressdistrictName \u2014 Electoral district name (e.g., \"Toronto Centre\")electedOffice \u2014 Position (e.g., \"MP\", \"MPP\", \"Councillor\")partyName \u2014 Political party affiliationrepresentativeSetName \u2014 Data source identifier (e.g., \"House of Commons\")url \u2014 Representative's official websitephotoUrl \u2014 Profile photo URLoffices \u2014 JSON array of office locations with contact infocachedAt \u2014 Timestamp when cached from Represent API/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
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:
postalCode (string): Canadian postal code (e.g., \"M5H2N2\" or \"M5H 2N2\")Query Parameters:
Parameter Type Required Default Description refresh boolean No false Force API call even if cached data existsExample 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:
source \u2014 Data source: \"cache\" (from database) or \"api\" (fresh from Represent API)postalCode \u2014 Normalized postal codelocation \u2014 City and province from PostalCodeCache tablerepresentatives \u2014 Array of representative objectsError Responses:
400 Bad Request: Invalid postal code format404 Not Found: Postal code not found in Represent API429 Too Many Requests: Rate limit exceeded (55/min)500 Internal Server Error: Represent API unreachable or other errorCaching 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:
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:
totalRepresentatives \u2014 Total cached representative recordspostalCodesWithRepresentatives \u2014 Unique postal codes with cached representativestotalPostalCodes \u2014 Total postal codes in PostalCodeCache table (includes codes without representatives)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 codeExample 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:
id (string): Representative ID (cuid)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:
404 Not Found: Representative not foundClear all cached representatives for a specific postal code.
Authentication: Required (Admin roles)
Path Parameters:
postalCode (string): Canadian postal codeExample 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:
Delete single cached representative by ID.
Authentication: Required (Admin roles)
Path Parameters:
id (string): Representative ID (cuid)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:
404 Not Found: Representative not foundThe 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:
Error('Represent API rate limit reached. Please try again in a minute.')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:
representatives_centroid \u2014 Representatives found using the postal code's geographic centroidrepresentatives_concordance \u2014 Representatives found using postal code concordance tables (may be more accurate for boundary-edge postal codes)Cache-first representative lookup.
Parameters:
code (string): Canadian postal codeforceRefresh (boolean, default: false): Skip cache and force API callReturns:
{\n source: 'cache' | 'api';\n postalCode: string;\n location: { city: string | null; province: string | null };\n representatives: Representative[];\n}\n Logic Flow:
forceRefresh=truesource: 'cache'forceRefresh, call Represent APIsource: '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?
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
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
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).
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:
page and limit coerced from query string to numberThe 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:
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 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:
@@index([postalCode]) \u2014 Fast lookup by postal codecachedAt DESC \u2014 Recent lookups firstDeduplication:
Cause: More than 55 requests in 60-second window
Solution:
Cause: Representative changed after election
Solution:
GET /api/representatives/by-postal/:postalCode?refresh=trueDELETE /api/representatives/by-postal/:postalCodeCause: Invalid postal code or Represent API doesn't have data
Solution:
Cause: Deduplication bug or manual database insertion
Solution:
DELETE /api/representatives/by-postal/:postalCodeThe 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:
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:
userId (allows upvoting from multiple devices)upvotedIp (prevents duplicate upvotes from same IP)/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
Submit a new representative response to a campaign.
Rate Limiting: 10 requests per minute per IP
Path Parameters:
slug (string): Campaign slugRequest 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:
representativeName (required): Representative's full namerepresentativeLevel (required): Government level (FEDERAL, PROVINCIAL, MUNICIPAL, SCHOOL_BOARD)responseType (required): Response type (EMAIL, LETTER, PHONE_CALL, MEETING, SOCIAL_MEDIA, OTHER)responseText (required): Full text of representative's responserepresentativeTitle (optional): Representative's title/positionrepresentativeEmail (optional): Representative's email (required if sendVerification=true)userComment (optional): Submitter's comment about the responsesubmittedByName (optional): Submitter's name (not shown if isAnonymous=true)submittedByEmail (optional): Submitter's email (not shown publicly)isAnonymous (optional, default: false): Hide submitter name on public wallsendVerification (optional, default: false): Send verification email to representativeResponse (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:
400 Bad Request: Campaign not active, response wall disabled, or validation error404 Not Found: Campaign not found429 Too Many Requests: Rate limit exceeded (10/min)Campaign Requirements:
status=ACTIVEshowResponseWall=trueList approved responses for a campaign.
Path Parameters:
slug (string): Campaign slugQuery 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:
APPROVED responses are returnedsubmittedByName is null if isAnonymous=truesubmittedByEmail never exposed on public routesrepresentativeEmail never exposed on public routesSorting:
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:
slug (string): Campaign slugExample 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:
total: Total APPROVED responses for campaignverified: Count of APPROVED responses with isVerified=truetotalUpvotes: Sum of all upvoteCount valuesbyLevel: Breakdown by government levelUpvote a response.
Authentication: Optional (supports both logged-in and anonymous users)
Path Parameters:
id (string): Response IDExample 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:
ResponseUpvote record:userId + responseId (allows upvoting from multiple IPs)upvotedIp + responseId (prevents duplicate upvotes from same IP)upvoteCount on responsealreadyUpvoted: trueError Responses:
400 Bad Request: Response is not approved404 Not Found: Response not foundRemove upvote from a response.
Authentication: Optional (supports both logged-in and anonymous users)
Path Parameters:
id (string): Response IDExample 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:
ResponseUpvote record matching responseId + userId (or upvotedIp if anonymous)upvoteCount if deletedsuccess: false if no upvote record foundVerify a response (representative confirms authenticity). Returns HTML result page.
Path Parameters:
id (string): Response IDtoken (string): Verification token (64-char hex)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:
reason: \"Invalid verification link\" \u2014 Token doesn't matchreason: \"Verification link has expired\" \u2014 More than 30 days since sentDatabase 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:
id (string): Response IDtoken (string): Verification token (same token as verify link)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:
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 submitterExample 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:
representativeEmail, submittedByEmail (sensitive fields)campaign relationSearch 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:
id (string): Response IDRequest 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:
404 Not Found: Response not foundUse Cases:
Resend verification email to representative.
Authentication: Required (Admin roles)
Path Parameters:
id (string): Response IDExample 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:
400 Bad Request: No representative email on record404 Not Found: Response not foundLogic:
verificationToken and verificationSentAt in databaserepresentativeEmailUse Cases:
Delete a response permanently.
Authentication: Required (Admin roles)
Path Parameters:
id (string): Response IDExample 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:
404 Not Found: Response not foundCascading Deletes:
ResponseUpvote records for this response (via Prisma cascade)Submit new response to campaign.
Parameters:
slug (string): Campaign slugdata (SubmitResponseInput): Response datasenderIp (string, optional): Submitter's IP addressReturns:
{\n id: string;\n status: ResponseStatus;\n verificationSent: boolean;\n}\n Validation:
showResponseWall=truesendVerification=true, representativeEmail is requiredVerification 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.
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:
responseId (string): Response IDuserIp (string, optional): User's IP addressuserId (string, optional): User ID (if logged in)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:
responseId (string): Response IDuserIp (string, optional): User's IP addressuserId (string, optional): User ID (if logged in)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:
responseId (string): Response IDtoken (string): Verification tokenReturns:
{\n success: boolean;\n campaignTitle?: string; // On success\n reason?: string; // On failure\n}\n Failure Reasons:
\"Invalid verification link\" \u2014 Token doesn't match\"Verification link has expired\" \u2014 More than 30 days oldReport a response as invalid via email link.
Parameters:
responseId (string): Response IDtoken (string): Verification token (same as verify link)Returns:
{\n success: boolean;\n campaignTitle?: string;\n reason?: string;\n}\n Database Changes:
status=REJECTEDisVerified=falseverifiedBy to \"Disputed by {email}\"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
Delete response permanently.
Throws: AppError(404) if not found
Resend verification email to representative.
Throws:
AppError(404) if response not foundAppError(400) if no representative email on recordexport 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:
The ResponseWallPage component (admin/src/pages/public/ResponseWallPage.tsx) provides:
Upvote Constraints:
alreadyUpvoted: trueIndexing:
@@index([campaignId]) \u2014 Fast filtering by campaign@@index([campaignSlug]) \u2014 Fast public lookup@@index([status]) \u2014 Fast admin filteringPagination:
Cause: SMTP configuration issue or email blocked by spam filter
Solution:
EMAIL_TEST_MODE=true in .env (emails go to MailHog)Cause: More than 30 days since verification email sent
Solution:
Cause: Already upvoted, or response not approved
Solution:
alreadyUpvoted: true in responsestatus=APPROVEDCause: Status is PENDING or REJECTED
Solution:
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:
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:
SiteSettings record exists in the databaseEncryption:
smtpPass encrypted at rest with AES-256-GCMENCRYPTION_KEY environment variable (must NOT reuse JWT secrets)/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:
401 Unauthorized: Missing or invalid access token403 Forbidden: Non-SUPER_ADMIN userImplementation:
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:
401 Unauthorized: Missing or invalid access token403 Forbidden: Non-SUPER_ADMIN user400 Bad Request: Invalid field valuesImplementation:
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:
emailTestMode: false to use real SMTPPurpose: 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:
smtpHostsmtpPortsmtpUsersmtpPasssmtpFromAddresstestEmailRecipientPurpose: 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":"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:
cm_shift_signups_total)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:
[shiftId, userEmail] \u2014 One signup per email per shift (allows re-activation of cancelled signups)/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
/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 Nodate 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:
id (string): Shift IDExample 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:
404 Not Found: Shift not foundCreate 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:
date must be YYYY-MM-DD formatstartTime, endTime must be HH:MM formatmaxVolunteers must be >= 1cutId is optional (for non-canvassing shifts)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:
userId)currentVolunteersFULL if capacity reachedError Responses:
400 Bad Request: Shift is full404 Not Found: Shift not found409 Conflict: Volunteer already signed upAdmin remove volunteer signup.
Path Parameters:
id (string): Shift IDsignupId (string): Signup IDResponse (204 No Content):
No response body.
Behavior:
CANCELLED (does not delete record)currentVolunteersOPENError Responses:
400 Bad Request: Signup already cancelled404 Not Found: Signup not foundEmail 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:
USER_NAME \u2014 Volunteer nameSHIFT_TITLE \u2014 Shift titleSHIFT_DATE \u2014 Formatted dateSHIFT_START_TIME \u2014 Start timeSHIFT_END_TIME \u2014 End timeSHIFT_LOCATION \u2014 LocationSHIFT_DESCRIPTION \u2014 DescriptionCURRENT_VOLUNTEERS \u2014 Current signup countMAX_VOLUNTEERS \u2014 Max capacitySHIFT_STATUS \u2014 StatusORGANIZATION_NAME \u2014 Site settings org nameGet 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:
isPublic: true)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:
Authenticated user signs up for shift.
Path Parameters:
id (string): Shift IDRate 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:
isPublic: true)Behavior:
currentVolunteersFULL if capacity reachedcm_shift_signups_totalError Responses:
400 Bad Request: Shift is full, cancelled, or past403 Forbidden: Shift is not public404 Not Found: Shift not found409 Conflict: Already signed up429 Too Many Requests: Rate limit exceededCancel own signup.
Path Parameters:
id (string): Shift IDExample 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:
CANCELLEDcurrentVolunteersOPENError Responses:
400 Bad Request: Already cancelled404 Not Found: Signup not foundList 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:
isPublic: true)Public signup with temporary user creation.
Path Parameters:
id (string): Shift IDRate 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:
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 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 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:
userIdsignupSource to AUTHENTICATEDBehavior \u2014 Re-activation:
If cancelled signup exists:
Transaction:
currentVolunteers increment + status update are atomicError Responses:
400 Bad Request: Shift full, not open, or past403 Forbidden: Shift not public404 Not Found: Shift not found409 Conflict: Already signed up429 Too Many Requests: Rate limit exceededList 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:
AppError(404) if shift not foundCreate 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:
AppError(400) if shift fullAppError(404) if shift not foundAppError(409) if already signed upPublic 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:
AppError(400) if shift full, not open, or pastAppError(403) if not publicAppError(404) if shift not foundAppError(409) if duplicate signupCancel 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:
USER_NAMESHIFT_TITLESHIFT_DATESHIFT_START_TIMESHIFT_END_TIMESHIFT_LOCATIONSHIFT_DESCRIPTIONCURRENT_VOLUNTEERSMAX_VOLUNTEERSSHIFT_STATUSORGANIZATION_NAMEError 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:
isPublic flag)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:
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:
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?
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:
\\d shift_signups in psqlawait prisma.$transaction([\n prisma.shiftSignup.findUnique({ /* check */ }),\n prisma.shiftSignup.create({ /* create */ }),\n], { isolationLevel: 'Serializable' });\nProblem:
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:
MailHog mode enabled:
EMAIL_TEST_MODE=true # Emails go to MailHog, not SMTP\n 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 Template missing:
# Check template exists\nls api/src/templates/shift-signup-confirmation.html\nls api/src/templates/shift-signup-confirmation.txt\n 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:
EMAIL_TEST_MODE=false for productionProblem:
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:
expiresAt is set to shift date + 1 daySolution:
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":"Shift.cutId)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:
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
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 statusExample 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:
id (string): User IDExample 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:
403 Forbidden: Non-admin trying to view another user404 Not Found: User ID does not existPermission 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 expirationResponse (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:
409 Conflict: Email already registered403 Forbidden: Non-admin trying to create userUpdate 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:
id (string): User ID to updateRequest 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:
403 Forbidden: Non-admin trying to update another user or change role/status404 Not Found: User ID does not exist409 Conflict: Email already in use by another userPermission 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:
id (string): User ID to deleteExample 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:
403 Forbidden: Non-admin trying to delete user404 Not Found: User ID does not existCascading Deletes:
Deleting a user automatically deletes: - Refresh tokens - Created campaigns (if createdByUserId relation) - Created locations (if createdByUserId relation) - Campaign emails - Responses - Shift signups
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).
Purpose: Create new user with hashed password.
Flow:
409 if duplicate)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:
404 if not found)409 if taken)expiresAt string to DateEmail 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":"select clause@unique constraint)409 Conflict)409 Conflict)Deleting a user automatically deletes related records via Prisma onDelete: Cascade:
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:
Email Service (email.service.ts)
Email Queue Service (email-queue.service.ts)
Geocoding Service (geocoding.service.ts)
Geocode Queue Service (geocode-queue.service.ts)
Listmonk Client (listmonk.client.ts)
Listmonk Sync Service (listmonk-sync.service.ts)
LISTMONK_SYNC_ENABLED)Pangolin Client (pangolin.client.ts)
Docker Service (docker.service.ts)
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":"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":"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:
Help other users:
Improve user experience:
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:
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:
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
Follow our coding standards:
npm run lint before committingnpm run format// 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:
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":"\u2192 Join Calls
"},{"location":"v2/contributing/#recognition","title":"Recognition","text":"We appreciate all contributors! Your name will be:
Top Contributors (all time):
\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":"Ready to contribute?
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:
Behavior that will not be tolerated includes:
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:
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:
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:
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:
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":"Node.js 20+ (download)
node --version # Should be v20.x.x or higher\n npm 10+ (comes with Node.js)
npm --version # Should be 10.x.x or higher\n Docker Desktop (download)
docker --version # Should be 20.10.x or higher\ndocker compose version # Should be 2.0.x or higher\n Git (download)
git --version # Should be 2.x.x or higher\n # 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
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.
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:
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:
.env.exampleDocumentation 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
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)
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\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":"changemaker-lite/v2 base: v2YOUR-USERNAME/changemaker-lite compare: feature/your-feature-name@changemaker-lite/maintainersAfter submitting, CI/CD runs these checks:
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:
No security vulnerabilities
Functionality:
No regressions
Tests:
Tests pass consistently
Documentation:
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":"# 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":"After addressing all feedback:
@reviewer Ready for re-reviewIssue: 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.
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:
v2 branchWhy 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:
git checkout v2\ngit pull upstream v2\ngit push origin v2\ngit branch -d feature/your-feature-name\ngit push origin --delete feature/your-feature-name\nFixes #NUse this before submitting:
"},{"location":"v2/contributing/pull-requests/#pre-submission","title":"Pre-Submission","text":"v2npx tsc --noEmit)npm run lint)npm run format)npm test succeedsv2Lint 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:
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":"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":"Features are prioritized based on:
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:
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":"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)
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):
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:
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)
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)
createdAt / updatedAt timestampscreatedByUserId / updatedByUserId user referencesAutomatic tracking via Prisma middleware
Soft Deletes \u2014 Some models use status fields instead of hard deletes:
status (ACTIVE/INACTIVE/SUSPENDED/EXPIRED)status (DRAFT/ACTIVE/PAUSED/ARCHIVED)Shift: status (OPEN/FULL/CANCELLED)
JSON Fields \u2014 Used for flexible schema:
permissions (User) \u2014 granular per-app permissionsoffices (Representative) \u2014 array of office contact infotags (videos) \u2014 array of tag stringsgeojson (Cut) \u2014 GeoJSON polygon coordinatesblocks (LandingPage) \u2014 GrapesJS editor output
Enums \u2014 18 enums for type safety:
UserRole, UserStatus, CampaignStatus, GovernmentLevel, EmailMethod, ResponseType, ResponseStatus, SupportLevel, GeocodeProvider, BuildingType, LocationHistoryAction, ShiftStatus, SignupStatus, SignupSource, CutCategory, VisitOutcome, CanvassSessionStatus, TrackPointEvent, EmailTemplateCategory, EditorMode, MkdocsExportMode
Cascade Deletes \u2014 Foreign keys with onDelete: Cascade:
Deleting a CanvassSession deletes all CanvassVisit records
Indexes \u2014 Strategic indexing for performance:
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":"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":"SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMPACTIVE, INACTIVE, SUSPENDED, EXPIREDADMIN, PUBLIC_SHIFT_SIGNUP, STANDARDDRAFT, ACTIVE, PAUSED, ARCHIVEDFEDERAL, PROVINCIAL, MUNICIPAL, SCHOOL_BOARDSMTP, MAILTOQUEUED, SENT, FAILED, CLICKED, USER_INFO_CAPTUREDEMAIL, LETTER, PHONE_CALL, MEETING, SOCIAL_MEDIA, OTHERPENDING, APPROVED, REJECTEDLEVEL_1 (mapped to \"1\"), LEVEL_2, LEVEL_3, LEVEL_4GOOGLE, MAPBOX, NOMINATIM, PHOTON, LOCATIONIQ, ARCGIS, UNKNOWNSINGLE_FAMILY, MULTI_UNIT, MIXED_USE, COMMERCIALCREATED, UPDATED, GEOCODED, BULK_GEOCODED, MOVED_ON_MAP, IMPORTED_CSV, IMPORTED_NAROPEN, FULL, CANCELLEDCONFIRMED, CANCELLEDAUTHENTICATED, PUBLIC, ADMINCUSTOM, WARD, NEIGHBORHOOD, DISTRICTNOT_HOME, REFUSED, MOVED, ALREADY_VOTED, SPOKE_WITH, LEFT_LITERATURE, COME_BACK_LATERACTIVE, COMPLETED, ABANDONEDLOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDEDINFLUENCE, MAP, SYSTEMVISUAL, CODETHEMED, STANDALONE'studios', 'gifs', 'private', 'inbox', 'curated', 'playback', 'compilations', 'videos', 'highlights''gpu_ai', 'gpu_encode', 'cpu''pending', 'queued', 'running', 'completed', 'failed', 'cancelled'All foreign key fields are indexed for join performance: - userId, campaignId, locationId, addressId, shiftId, cutId, sessionId, templateId, trackingSessionId
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
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
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":"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":"email \u2014 Login lookups (WHERE email = ?)token \u2014 Refresh endpoint lookups (WHERE token = ?)userId \u2014 User deletion cascadesslug \u2014 Public campaign page lookups (WHERE slug = ?)postalCode \u2014 Postal code lookups (WHERE postalCode = ?)campaignId \u2014 Campaign email stats (JOIN campaign_emails ON campaign_id = ?)campaignSlug \u2014 Slug-based queriescampaignId \u2014 Campaign response wall (JOIN representative_responses ON campaign_id = ?)campaignSlug \u2014 Slug-based queries[responseId, userId] \u2014 Prevent duplicate upvotes from logged-in users[responseId, upvotedIp] \u2014 Prevent duplicate upvotes from same IPcampaignId \u2014 Campaign custom recipients (JOIN custom_recipients ON campaign_id = ?)postalCode \u2014 Postal code cache lookups (WHERE postal_code = ?)campaignId \u2014 Campaign call tracking (JOIN calls ON campaign_id = ?)locGuid \u2014 NAR location GUID lookups[latitude, longitude] \u2014 Spatial queries (nearby locations, bounding box searches)postalCode \u2014 Postal code filteringQuery 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":"addrGuid \u2014 NAR address GUID lookupslocationId \u2014 Location addresses (JOIN addresses ON location_id = ?)[locationId, unitNumber] \u2014 Unit lookups within buildingQuery 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":"locationId \u2014 Location history (JOIN location_history ON location_id = ?)userId \u2014 User edit history (JOIN location_history ON user_id = ?)createdAt \u2014 Temporal queries (recent edits, audit trails)cutId \u2014 Cut shifts (JOIN shifts ON cut_id = ?)[shiftId, userEmail] \u2014 Prevent duplicate shift signupsshiftId \u2014 Shift signups (JOIN shift_signups ON shift_id = ?)userId \u2014 User canvass sessions (JOIN canvass_sessions ON user_id = ?)cutId \u2014 Cut canvass sessions (JOIN canvass_sessions ON cut_id = ?)shiftId \u2014 Shift canvass sessions (JOIN canvass_sessions ON shift_id = ?)addressId \u2014 Address visit history (JOIN canvass_visits ON address_id = ?)userId \u2014 User visit history (JOIN canvass_visits ON user_id = ?)shiftId \u2014 Shift visits (JOIN canvass_visits ON shift_id = ?)sessionId \u2014 Session visits (JOIN canvass_visits ON session_id = ?)visitedAt \u2014 Temporal queries (recent visits, activity feeds)canvassSessionId \u2014 One-to-one relationship with CanvassSessionuserId \u2014 User GPS sessions (JOIN tracking_sessions ON user_id = ?)isActive \u2014 Active session filtering (WHERE is_active = true)[isActive, lastRecordedAt] \u2014 Session cleanup queries (abandoned sessions)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":"[trackingSessionId, recordedAt] \u2014 Temporal GPS queries (session breadcrumb trail)recordedAt \u2014 Cross-session temporal querieskey \u2014 Template key lookups (WHERE key = 'campaign-email')category \u2014 Category filtering (WHERE category = 'INFLUENCE')isActive \u2014 Active template filtering (WHERE is_active = true)[templateId, key] \u2014 Unique variable keys per templatetemplateId \u2014 Template variables (JOIN email_template_variables ON template_id = ?)[templateId, versionNumber] \u2014 Sequential version numbers per template[templateId, createdAt(sort: Desc)] \u2014 Recent version historyQuery 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":"[templateId, sentAt(sort: Desc)] \u2014 Recent test logsslug \u2014 Public page lookups (WHERE slug = 'about')path \u2014 File path lookups (WHERE path = '/media/local/videos/file.mp4')orientation \u2014 Orientation filtering (WHERE orientation = 'landscape')producer \u2014 Producer filtering (WHERE producer = 'Studio A')isValid \u2014 Valid video filtering (WHERE is_valid = true)directoryType \u2014 Directory type filtering (WHERE directory_type = 'studios')[durationSeconds, fileSize, width, height] \u2014 Fingerprint matching (duplicate detection)[directoryType, isValid, orientation] \u2014 Common filtering patternQuery 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":"[status, priority, createdAt] \u2014 Job queue processing[resourceCategory, status] \u2014 Resource-based filteringpipelineId \u2014 Pipeline job filteringQuery 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":"Optimization: - Use low-selectivity indexes as first column in composite index only - Example: [isActive, lastRecordedAt] uses isActive to narrow search, then lastRecordedAt for ordering
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)
Query:
SELECT COUNT(*) FROM campaign_emails WHERE campaign_id = ?;\n Solution: Already optimized (uses campaignId foreign key index)
Query:
SELECT * FROM locations WHERE latitude > ? AND latitude < ? AND longitude > ? AND longitude < ?;\n Solution: Already optimized (uses [latitude, longitude] composite index)
Query:
SELECT * FROM tracking_sessions WHERE is_active = true AND last_recorded_at < ?;\n Solution: Already optimized (uses [isActive, lastRecordedAt] composite index)
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)
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
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":"@default() for new required fieldsprisma db push in production (skips migrations)prisma migrate reset in production (deletes data)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 Descriptionnpx 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
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":"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 Userusers 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.
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.
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.
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.
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.
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.
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.
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).
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
\"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":"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
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
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
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)
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.
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
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.
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:
Advocacy campaign models:
Location and geographic models:
Door-to-door canvassing models:
Landing pages and content:
Email template system:
Video library (Drizzle ORM):
Global configuration:
Used for 95% of models:
api/prisma/schema.prismaapi/prisma/migrations/Used for media models only:
api/src/modules/media/db/schema.tsMost 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:
npx prisma studioKey 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:
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":"Int @id @default(autoincrement())String or String @db.Text (long text)Int or FloatBoolean @default(false)DateTime @default(now())Json or Json?Role, VisitOutcome, etc.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":"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 Userusers 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 \u2713cuid() 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[]
email (case-insensitive via Prisma transform)expiresAt setexpiresAt < now()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 \u2713cuid() 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":"user \u2192 User (onDelete: Cascade) \u2014 deleting user deletes all refresh tokenstoken (fast lookup for refresh endpoint)userId (join to User)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":"WHERE email = ?)WHERE token = ?)include or selectfindFirst instead of findMany().take(1) for single record queriesskip + take + cursor-based pagination for large datasets// \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":"/api/auth/me returns 401 (not 404) for missing usersMiddleware 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":"requireNonTemp middleware)expireDays (default 30)Cause: Email uniqueness constraint violated Solution: Check for existing user: prisma.user.findUnique({ where: { email } })
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
Cause: Both modules import each other Solution: Use callback registration pattern (see admin/src/lib/api.ts + admin/src/stores/auth.store.ts)
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)
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
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
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":"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":"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":"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
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)
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":"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)
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)
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.
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":"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/%')
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
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]
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
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":"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":"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":"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:
Comprehensive environment configuration:
Reverse proxy and routing:
HTTPS configuration:
Public access via tunneling:
Data protection:
Observability and alerting:
Container health monitoring:
Horizontal and vertical scaling:
Prepare Server
# Ubuntu/Debian server with Docker installed\napt update && apt install docker.io docker-compose git\n Clone Repository
git clone <repo-url> changemaker.lite\ncd changemaker.lite\ngit checkout v2\n Configure Environment
cp .env.example .env\n# Edit .env with your settings\n 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 Access Application
http://server-ip:3000\nLogin: admin@example.com / Admin123!\n Deploy Newt container
Enable Monitoring
docker compose --profile monitoring up -d\n Set Up Backups
# Configure backup.sh\n./scripts/backup.sh\n\n# Add to crontab\n0 2 * * * /path/to/backup.sh\n Secure Installation
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":"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":"Pull Latest Code
git pull origin v2\n Rebuild Containers
docker compose build\ndocker compose up -d\n Run Migrations
docker compose exec api npx prisma migrate deploy\n Verify Services
docker compose ps\ncurl http://localhost:4000/health\n 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":"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
# 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).
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).
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.
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:
git clone <repo> changemaker.lite\ncd changemaker.lite\ngit checkout v2\naws s3 cp s3://changemaker-backups/changemaker-backups/latest.tar.gz ./\ndocker compose up -d\ndocker compose ps\ncurl http://localhost:4000/api/health\nRTO (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":"chmod 600 .env)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:
docker-compose.yml--profile monitoring flagdepends_on relationshipsArchitecture:
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.
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.
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.
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)
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
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
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
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
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)
Named volumes (Docker-managed, persistent across container recreation):
Volume Purpose Size Estimatev2-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)
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}
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)
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.
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
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).
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)
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)
# 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 DescriptionNODE_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.
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).
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 DescriptionADMIN_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 DescriptionNGINX_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).
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
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 DescriptionREPRESENT_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 DescriptionNOCODB_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 DescriptionGITEA_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 DescriptionMKDOCS_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 DescriptionCODE_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
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.
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 DescriptionMAILHOG_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 DescriptionNAR_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 DescriptionMAPBOX_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.
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 DescriptionPROMETHEUS_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 DescriptionGRAFANA_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/
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.
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:
CHANGE_ME_* passwordsJWT_ACCESS_SECRET (32+ chars)JWT_REFRESH_SECRET (32+ chars)ENCRYPTION_KEY (32+ chars, different from JWT secrets)REDIS_PASSWORDV2_POSTGRES_PASSWORDLISTMONK_DB_PASSWORDLISTMONK_API_TOKENGITEA_DB_PASSWD + GITEA_DB_ROOT_PASSWORDN8N_ENCRYPTION_KEY + N8N_USER_PASSWORDNC_ADMIN_PASSWORD (NocoDB)GRAFANA_ADMIN_PASSWORDEMAIL_TEST_MODE (set to false)NODE_ENV=productionCORS_ORIGINS (whitelist only trusted domains)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":"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)
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).
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.
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
# 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
# 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).
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":"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 Descriptioncm_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
File: configs/prometheus/alerts.yml
12 alert rules across 4 groups:
"},{"location":"v2/deployment/monitoring-stack/#application-alerts","title":"Application Alerts","text":"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":"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:
api.cmlite.org, app.cmlite.org, db.cmlite.org, etc.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 Typenginx/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
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)
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)
# 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).
# 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).
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.
File: nginx/conf.d/services.conf
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
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
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.
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)
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)
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
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)
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.
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":"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)
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":"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
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)
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):
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":"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
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).
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
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":"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:
Docker-based development:
Version control best practices:
Common development commands:
Schema changes and migrations:
TypeScript best practices:
Coding standards and conventions:
Testing strategies:
Debugging techniques:
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":"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":"*.schemas.ts*.service.ts*.routes.tsserver.tspages/App.tsxprisma/schema.prismanpx prisma migrate dev --name add_fieldservices/# 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":"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)
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\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":"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":"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":"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:
Conditional Breakpoints: - Right-click line number - Select \"Add conditional breakpoint\" - Enter condition: user.id === 1 - Pauses only when condition is true
Debug API calls:
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:
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:
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":"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
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
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
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
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
What are the exact steps?
Isolate:
Is it specific to one user/data/scenario?
Gather Information:
Check error messages
Form Hypothesis:
What evidence supports this?
Test Hypothesis:
Test specific scenario
Fix:
Don't fix multiple issues at once
Verify:
Check for side effects
Prevent:
// 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":"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)
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)
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:
api:\n volumes:\n - ./api:/app:delegated # Slightly better performance\n node_modules\ndist\ncoverage\n.git\n*.log\n # 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
# 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:
api or admin container/app folder in containerEnable 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:
services:\n api:\n volumes:\n - ./api:/app:delegated\n // vite.config.ts\nexport default {\n server: {\n watch: {\n ignored: ['**/node_modules/**', '**/dist/**']\n }\n }\n}\n 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":"Start services in background:
docker compose up -d api admin\n Watch logs in separate terminal:
docker compose logs -f api admin\n Make code changes:
Hot reload picks up changes automatically
Type-check before commit:
docker compose exec api npm run type-check\ndocker compose exec admin npm run type-check\n Stop services when done:
docker compose stop\n 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":"Use .env file (not docker-compose.yml):
# .env\nAPI_PORT=4000\nADMIN_PORT=3000\n Reference in docker-compose.yml:
services:\n api:\n environment:\n - API_PORT=${API_PORT}\n Don't commit .env (use .env.example).
Named volumes for data:
volumes:\n v2-postgres-data: # Persistent database\n Bind mounts for code:
volumes:\n - ./api:/app # Live code sync\n Anonymous volumes for dependencies:
volumes:\n - /app/node_modules # Isolate from host\n Use log rotation:
services:\n api:\n logging:\n driver: \"json-file\"\n options:\n max-size: \"10m\"\n max-file: \"3\"\n Filter logs with grep:
docker compose logs -f api | grep ERROR\n Export logs for analysis:
docker compose logs > debug-logs.txt\n # 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":"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
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
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
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
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:
v2, compare: feature/add-user-avatar# 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":"Use module/area name:
auth - Authenticationusers - User managementcampaigns - Campaign modulemap - Map featuresemail - Email systemdb - Database changesui - UI componentsapi - API changesSimple 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:
Requests reviewers
Reviewers review
Leave comments/suggestions
Author addresses feedback
Re-requests review
Final approval
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
# 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:
Clone your fork:
git clone https://github.com/your-username/changemaker.lite.git\n Add upstream remote:
git remote add upstream https://github.com/original/changemaker.lite.git\n Create feature branch:
git checkout -b feature/my-feature\n Make changes, commit, push to your fork:
git push origin feature/my-feature\n Create pull request from your fork to upstream
# 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":"git add .)git diff --staged)git add . (stage specific files)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:
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:
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.
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:
# 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!
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
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":"admin@example.comAdmin123!/app (admin dashboard)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":"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
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
typescript-language-server@prisma/language-serverProblem: 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
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)
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":"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":"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:
Database Migrations - Schema change workflow
Understand Architecture:
Database Schema - Data models
Learn Code Patterns:
Testing Guide - Test writing
Start Contributing:
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
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
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
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
# 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":"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
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
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/
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
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
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
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/
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:
# 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
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
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)
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
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
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
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
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":"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":"Allows refactoring without breaking tests
Arrange-Act-Assert (AAA) Pattern
Assert: Verify expected behavior
Independent Tests
Tests can run in any order
Fast Feedback
Run full suite in CI/CD
Readable Tests
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
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":"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":"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:
Email advocacy campaigns and representative outreach:
Geographic location management and canvassing:
Website page building and management:
Email template system for campaigns:
Video library management:
Listmonk newsletter platform integration:
Pangolin tunnel for public access:
Monitoring and metrics:
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:
Completed Files Reference:
api/src/modules/influence/campaigns/ \u2192 campaigns.mdapi/src/modules/influence/representatives/ \u2192 representatives.mdapi/src/modules/influence/responses/ \u2192 responses.mdapi/src/services/email-queue.service.ts \u2192 email-queue.mdadmin/src/pages/CampaignsPage.tsx \u2192 campaigns.mdadmin/src/pages/ResponsesPage.tsx \u2192 responses.mdFor Remaining Files:
api/src/modules/map/locations/ \u2192 locations.mdapi/src/modules/map/geocoding/ \u2192 geocoding.mdapi/src/modules/map/cuts/ \u2192 cuts.mdapi/src/modules/map/shifts/ \u2192 shifts.mdapi/src/modules/map/canvass/ \u2192 canvassing.mdapi/src/modules/map/tracking/ \u2192 tracking.mdapi/src/modules/pages/ \u2192 page-builder.md, block-library.mdapi/src/modules/email-templates/ \u2192 template-system.md, editor.md, variables.md, versioning.mdapi/src/modules/media/ \u2192 video-library.md, upload.md, jobs.md, public-gallery.mdapi/src/services/listmonk.client.ts \u2192 listmonk-integration.mdapi/src/services/pangolin.client.ts \u2192 pangolin-setup.mdapi/src/utils/metrics.ts \u2192 prometheus-metrics.mdTotal 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":"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:
Dynamic placeholders:
{{user.name}}, {{user.email}}{{campaign.name}}, {{campaign.description}}{{rep.name}}, {{rep.title}}, {{rep.email}}{{site.name}}, {{current.date}}/app/email-templates)Save draft
Edit Template (/app/email-templates/:id/edit)
Save changes
Use Template
Send email with processed template
Manage Versions (/app/email-templates/:id/versions)
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
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
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":"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":"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)
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:
{{VAR}} at cursorLocation: 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:
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:
onChange event fireshtmlContent stateTriggers debounced preview render (300ms)
Debounced Render
Injects HTML into iframe
Sample Data Changes
sampleData statePreview 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
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":"GET /api/email-templates/:id \u2014 Load template + variablesPUT /api/email-templates/:id \u2014 Update template (creates version)POST /api/email-templates/:id/test \u2014 Send test emailThe 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:
{{VAR}} syntax powered by Handlebars template engineUse Cases:
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:
Core template storage with metadata and content.
Field Type Descriptionid 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
Variable definitions for template interpolation.
Field Type Descriptionid 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
Version history snapshots for audit trail and rollback.
Field Type Descriptionid 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)
Test send audit trail for debugging and compliance.
Field Type Descriptionid 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
Purpose: Advocacy campaign emails sent to representatives or response notifications to participants.
System Templates:
Key Name Descriptioncampaign-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
Purpose: Location-based emails for volunteer shifts, canvassing sessions, and shift management.
System Templates:
Key Name Descriptionshift-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
Purpose: Core platform emails for user management, authentication, and system notifications.
System Templates:
Key Name Descriptionuser-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
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
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
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.
Shows table with all templates grouped by category
Filter and Search
Toggle \"Show Inactive\" to view disabled templates
Template Details
Opens template creation modal
Enter Template Metadata
System Flag \u2014 Check if template is critical (prevents deletion)
Define Variables
Set sort order (drag to reorder)
Write Template Content
Text Content \u2014 Write plain text fallback
Save Template
Opens detail modal
Click \"Edit\" Button
Shows split-pane editor (HTML + Text)
Modify Content
Preview rendered output with sample data
Add Change Notes
Used for version history audit trail
Save Changes
Click template from list
Navigate to \"Test Send\" Tab
Enter Test Parameters
Pre-filled with variable sample values
Click \"Send Test Email\"
Success/failure notification displayed
Check Test Log
Open Template Detail Modal
Toggle \"Active\" Switch
Useful for disabling seasonal templates or broken templates
Confirm Action
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)
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 &, < \u2192 <, > \u2192 >
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":"GET /api/email-templates \u2014 List templates (with filters)POST /api/email-templates \u2014 Create templatePUT /api/email-templates/:id \u2014 Update templateDELETE /api/email-templates/:id \u2014 Delete template (system templates protected)POST /api/email-templates/:id/test \u2014 Send test emailGET /api/email-templates/:id/versions \u2014 Version historyPOST /api/email-templates/:id/rollback/:versionNumber \u2014 Restore versionsendFromTemplate() \u2014 Load, validate, interpolate, sendsend() \u2014 Low-level Nodemailer wrapperEMAIL_TEST_MODE \u2014 Enable MailHog captureThe 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:
{{#if}} blocksBenefits:
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:
{{VARIABLES}}{{VAR}} with data valuesTable: email_template_variables
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, []
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:
Opens template detail modal
Navigate to \"Variables\" Tab
Columns: Key, Label, Required, Conditional, Sample Value, Sort Order
Variable Details
From EmailTemplateEditorPage:
Variables shown in right sidebar
Variable Insertion Panel
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).
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 DescriptionUSER_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 DescriptionUSER_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 DescriptionUSER_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
GET /api/email-templates/:id/variables \u2014 List variablesPOST /api/email-templates/:id/variables \u2014 Create variablePUT /api/email-templates/:id/variables/:varId \u2014 Update variableDELETE /api/email-templates/:id/variables/:varId \u2014 Delete variableThe 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:
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:
Table: email_template_versions
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":"GET /api/email-templates/:id/versions \u2014 List versionsGET /api/email-templates/:id/versions/:versionNumber \u2014 Get version detailsPOST /api/email-templates/:id/rollback/:versionNumber \u2014 Rollback to versionThe 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:
/campaigns)Click campaign to learn more
Campaign Detail (/campaigns/:id)
Send email
Response Wall (/responses/:campaignId)
/app/influence/campaigns)Manage visibility
Response Moderation (/app/influence/responses)
Monitor engagement
Representative Cache (/app/influence/representatives)
Monitor lookup statistics
Email Queue (/app/influence/email-queue)
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
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
# 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
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
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":"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:
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:
See Campaign Model Documentation for full schema.
Key Fields:
status: DRAFT | ACTIVE | PAUSED | ARCHIVEDtargetGovernmentLevels: Array of government levels (federal, provincial, municipal)emailSubjectTemplate: Subject line with {{VAR}} placeholdersemailBodyTemplate: Email body with {{VAR}} placeholderscoverPhotoUrl: Campaign hero image URLslug: URL-friendly identifierFeature Flags (12 total):
Flag Type Default DescriptionallowSmtpEmail 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:
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:
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:
Best Practices:
requireEmailVerification for public response wallsallowCustomMessage if you want consistent messaginghighlightCampaign sparingly (max 2-3 campaigns)showProgressBar to encourage participation[Screenshot: Campaign preview with test email form]
Steps:
/campaigns/{slug}Troubleshooting:
[Screenshot: Campaign status dropdown]
Steps:
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:
Metrics to Track:
[Screenshot: Public campaigns list page with featured campaigns]
User Journey:
/campaignshighlightCampaign enabled)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:
/campaigns/{slug}[Screenshot: Email form with representative selection]
User Journey:
allowCustomMessage enabledCode 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:
/responses/{campaignId}/submitrequireEmailVerification enabled \u2192 verification email sentNot 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:
ACTIVE/api/public/campaignsDebugging:
# 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:
{{VAR}}processTemplate() calledsenderName, senderEmail, postalCode, recipientName, recipientEmailCode 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:
/uploads/campaigns must be writableclient_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:
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:
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:
cm_email_queue_size metricImage 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:
srcsetThe 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:
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:
See CampaignEmail Model Documentation for full schema.
Key Fields:
Field Type Descriptionid 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:
campaignId, status \u2014 For campaign email statsjobId \u2014 For job status lookupssentAt \u2014 For time-based queriesRelated Models:
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 DescriptionREDIS_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:
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:
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:
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:
Bulk Retry:
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:
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:
docker compose logs api | grep \"Worker\"docker compose exec redis redis-cli ping/api/auth/test-emaildocker compose restart apiDebugging:
# 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:
failureReason field in databaseCommon 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:
docker compose ps redisREDIS_PASSWORD matches docker-compose.ymldocker compose exec redis redis-cli --pass $REDIS_PASSWORD pingFix 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:
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":"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:
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 DescriptionpostalCode String Normalized postal code (primary key) latitude Float Centroid latitude longitude Float Centroid longitude city String? City name province String? Province abbreviation Indexes:
postalCode \u2014 Primary key, unique constraintRelated Models:
/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 DescriptionGEOCODING_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:
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":"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:
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:
See Representative Model Documentation for full schema.
Key Fields:
Field Type Descriptionid 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:
postalCode, level \u2014 Composite index for fast lookupsrepresentId \u2014 Unique constraintlastUpdated \u2014 For cache invalidation queriesRelated Models:
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:
GET /postcodes/:postalCode/ \u2014 Lookup representatives by postal codeGET /representatives/ \u2014 List representatives (unused, direct lookups only)Rate Limits:
Postal Code Format:
K1A 0A1 or K1A0A1 (space optional)[Screenshot: RepresentativesPage with cache stats cards]
Steps:
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:
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:
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:
Bulk Delete by Postal Code:
[Screenshot: CampaignPage with postal code input field]
User Journey:
/campaigns/{slug})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:
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:
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:
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:
REPRESENT_CACHE_TTL to 7 days (604800)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:
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":"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:
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:
See Response Model Documentation for full schema.
Key Fields:
Field Type Descriptionid 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:
campaignId, status \u2014 For public wall queriesemail, campaignId \u2014 Prevent duplicate submissionsisEmailVerified \u2014 Filter unverified responsesSee ResponseUpvote Model Documentation for full schema.
Key Fields:
Field Type Descriptionid String (UUID) Primary key responseId String Associated response ipAddress String? Voter IP address userId String? Voter user ID (if logged in) Constraints:
responseId, ipAddress \u2014 Prevent duplicate upvotes by IPresponseId, userId \u2014 Prevent duplicate upvotes by userRelated Models:
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 DescriptionshowResponseWall 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:
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:
Moderation Checklist:
[Screenshot: Response detail drawer with approve/reject buttons]
Steps:
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:
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:
/responses/{campaignId}/submitCode 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:
/api/public/responses/verify/{token}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:
/responses/{campaignId}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:
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:
docker compose logs api | grep \"verification\"/api/auth/test-emailManual 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:
responseId, ipAddressreq.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:
/uploads/responses must be writableclient_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":"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:
Pre-built components:
/app/pages)Save draft
Edit Page (/app/pages/:id/edit)
Preview changes
Publish Page
/p/:slugListed in page table
Export to MkDocs (/app/services/docs)
/p/:slug)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
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
# 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:
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":"mkdocs/docs/overrides/{% 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":"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:
/app/map/locations)Bulk operations
Create Cuts (/app/map/cuts)
Export for printing
Schedule Shifts (/app/map/shifts)
Monitor signups
Monitor Canvassing (/app/canvass/dashboard)
Review activity feed
Print Materials (/app/canvass/walk-sheet)
/volunteer/assignments)Start canvass button
Canvass (/volunteer/canvass/:cutId)
Track progress
Review Activity (/volunteer/activity)
/map)Geolocate self
Sign Up for Shifts (/shifts)
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
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
# 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
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":"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":"Multi-provider support
\u2705 geocoding.md (1029 lines) \u2014 Multi-provider geocoding service
Provider health tracking
\u2705 cuts.md (924 lines) \u2014 Geographic polygon overlays
Completion tracking
\u2705 shifts.md (946 lines) \u2014 Volunteer shift management
Volunteer + admin workflows
\ud83d\udea7 tracking.md \u2014 GPS tracking system
Live volunteer tracking
\ud83d\udea7 walk-sheets.md \u2014 Printable walk sheets + QR codes
Browser print API
\ud83d\udea7 data-quality.md \u2014 Geocoding quality dashboard
Duplicate detection
\ud83d\udea7 nar-import.md \u2014 NAR 2025 electoral data import
Continue creating remaining 5 files following the established 12-section structure:
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:
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:
See CanvassSession Model Documentation for full schema.
Key Fields:
userId: Foreign key to volunteer UsercutId: Foreign key to Cut (territory)shiftId: Optional foreign key to Shift (if started from shift)status: ACTIVE | COMPLETED | ABANDONEDstartedAt: Session start timestampendedAt: Session end timestamp (null while active)totalVisits: Count of CanvassVisit recordscompletionPercentage: Auto-calculated from cut progressStatus 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:
sessionId: Foreign key to CanvassSessionuserId: Foreign key to volunteer UseraddressId: Foreign key to Address (specific unit visited)outcome: Visit result (7 types)supportLevel: Updated support level (LEVEL_1-4 or null)signRequested: Boolean - resident wants lawn/window signnotes: Free-text canvass notesvisitedAt: Visit timestampdurationSeconds: Time spent at door (auto-calculated)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:
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:
rl:canvass-visit: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:
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:
Step 1: View Leaderboard
Leaderboard card displays:
Step 2: Filter by Time Period
Toggle time period:
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:
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:
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:
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:
/volunteer/activity (visit history page)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:
// 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:
// Always pass volunteer GPS position to route calculation\nconst route = await calculateWalkingRoute(\n locations,\n currentLat,\n currentLng,\n cut.geojson\n);\n 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 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:
CANVASS_SESSION_TIMEOUT_HOURS set too lowSolutions:
# In .env\nCANVASS_SESSION_TIMEOUT_HOURS=24 # Was 12, increase to 24\n 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 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:
enableHighAccuracy uses GPS + WiFi + cellular (power-hungry)Solutions:
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 // Submit GPS points every 30s instead of 10s\nconst SUBMIT_INTERVAL_MS = 30000; // Was 10000\n 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:
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:
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:
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:
See Cut Model Documentation for full schema.
Key Fields:
name: Cut display name (e.g., \"Ward 5 - Downtown\")description: Free-text notes about the cutgeojson: Polygon coordinates in GeoJSON format (TEXT field)bounds: Auto-calculated bounding box {minLat, maxLat, minLng, maxLng} (JSON)color: Hex color for map overlay (default: #3498db)opacity: Opacity 0.0-1.0 for map rendering (default: 0.3)category: CUSTOM | WARD | NEIGHBORHOOD | DISTRICTisPublic: Show on public mapisOfficial: Official electoral boundary (prevents accidental deletion)showLocations: Show location markers within cut on mapexportEnabled: Allow walk sheet export for this cutassignedTo: Free-text assigned volunteer/team namecompletionPercentage: Auto-calculated canvassing progress (0-100)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:
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:
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:
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:
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:
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:
// 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 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:
// 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 -- 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 # 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:
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 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 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:
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:
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:
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:
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:
Detect duplicates via coordinate matching
Quality Review:
Identifies patterns (provider failures, address format issues)
Remediation:
Duplicate merging or marking
Monitoring:
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:
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:
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 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 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 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
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 0Step 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
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:
New confidence displayed
Edit address:
Auto-triggers re-geocoding
View on map:
Step 1: Select Locations
Step 2: Choose Provider
\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\nStep 3: Monitor Progress
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 Real-time updates:
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
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:
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:
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:
Run quality report:
curl http://localhost:4000/api/locations/geocode-stats\n Check metrics against thresholds:
Duplicates < 20
Review low-confidence locations:
Identify patterns (specific streets, providers)
Bulk re-geocode low confidence:
Monitor improvement in avg confidence
Resolve duplicates:
Update addresses as needed
Export quality report:
const report = await generateQualityReport();\nfs.writeFileSync(`quality-report-${date}.json`, JSON.stringify(report, null, 2));\n 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:
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 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 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 Use postal code for better accuracy:
// Append postal code if available\nconst fullAddress = location.postalCode\n ? `${location.address}, ${location.postalCode}`\n : location.address;\n 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 Symptoms: - Multiple locations at same coordinates - Duplicates tab shows many groups - Inflated location counts in cuts
Solutions:
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 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 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 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 Symptoms: - GET /api/locations/geocode-stats takes > 5 seconds - Dashboard timeout errors - High database CPU
Solutions:
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 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 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 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 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:
Application Cache (Redis):
// 30-day TTL for geocode results\nconst cacheKey = `geocode:${normalizeAddress(address)}`;\nawait redis.setex(cacheKey, 2592000, JSON.stringify(result));\n Statistics Cache:
// 5-minute TTL for stats\nawait redis.setex('geocode:stats', 300, JSON.stringify(stats));\n 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":"api/src/modules/map/locations/locations.service.tsRe-geocoding operations
Geocoding Service: api/src/modules/map/geocoding/geocoding.service.ts
Cache integration
Bulk Geocoding: api/src/modules/map/locations/bulk-geocode.routes.ts
admin/src/pages/DataQualityDashboardPage.tsxBulk actions
Locations Page: admin/src/pages/LocationsPage.tsx
api/prisma/schema.prismaapi/src/utils/metrics.tsAlert integration
Grafana Dashboard: configs/grafana/dashboards/data-quality.json
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:
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:
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:
latitude / longitude: Decimal coordinates from geocodinggeocodeConfidence: Integer 0-100 (>90=high, 70-90=medium, <70=low)geocodeProvider: Which provider successfully geocodedgeocodeAttempts: Number of failed attempts (for retry logic)lastGeocodeAttempt: Timestamp of last geocoding attemptRelated Models:
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:
Provider Priority (Default):
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-9Confidence Thresholds:
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:
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:
# 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 # View Prometheus metrics\ncurl http://localhost:4000/metrics | grep cm_geocode\n\n# View API logs\ndocker compose logs -f api | grep geocode\n # 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:
// 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 # 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 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:
# 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 # Restart API service (worker runs in API container)\ndocker compose restart api\n # 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 // 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:
cm_geocode_cache_hits metric always 0Causes:
GEOCODING_CACHE_ENABLED=falseSolutions:
# 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 # 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 # Verify in .env\nGEOCODING_CACHE_ENABLED=true\nGEOCODING_CACHE_TTL_HOURS=168 # 7 days\n # 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:
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:
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:
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:
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:
See Location Model Documentation for full schema.
Key Fields:
latitude / longitude: WGS84 coordinates (Decimal type for precision)address: Street address (building level, not including unit numbers)postalCode: Canadian postal code (A1A 1A1 format)province: Province code (ON, QC, AB, etc.)federalDistrict: Federal electoral district namebuildingType: SINGLE_FAMILY | MULTI_UNIT | MIXED_USE | COMMERCIALtotalUnits: Number of units in building (for multi-unit buildings)geocodeConfidence: Confidence score 0-100 from geocoding servicegeocodeProvider: Google, Mapbox, Nominatim, Photon, LocationIQ, ArcGISnarLocGuid: NAR LOC_GUID identifier (Canadian electoral data)buildingNotes: Free-text notes about building access, parking, etc.NAR-Specific Fields:
narLocGuid: Location GUID from NAR datasetbuildingUse: Building use code (1=Residential, 2=Commercial, etc.)postalCode: Extracted from NAR MAIL_POSTAL_CODEprovince: Extracted from NAR PROV_CODEfederalDistrict: Extracted from NAR FED_ENG_NAMEGeocoding Fields:
geocodeConfidence: 0-100 score (>90=high, 70-90=medium, <70=low)geocodeProvider: Which provider successfully geocoded the addressgeocodeAttempts: Number of failed geocoding attemptslastGeocodeAttempt: Timestamp of last geocoding attemptSee Address Model Documentation for full schema.
Key Fields:
locationId: Foreign key to Location (building)unitNumber: Unit/apartment/suite number (optional for single-family)firstName / lastName: Resident nameemail / phone: Contact informationsupportLevel: LEVEL_1 (Strong) | LEVEL_2 (Leaning) | LEVEL_3 (Undecided) | LEVEL_4 (Opposed)sign: Boolean - has lawn/window signsignSize: Sign size description (e.g., \"24x18 lawn\", \"window\")notes: Free-text notes from canvassingnarAddrGuid: NAR ADDR_GUID identifierNAR-Specific Fields:
narAddrGuid: Address GUID from NAR datasetunitNumber: Extracted from NAR APT_NO_LABELRelated Models:
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:
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:
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:
/data directory for Address_.csv and Location_.csv filesStep 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:
Step 7: Monitor Progress
View real-time progress:
Step 8: Review Results
After import completes:
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:
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:
// 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:
# Verify API keys are set in .env\ngrep \"GOOGLE_MAPS_API_KEY\\|MAPBOX_ACCESS_TOKEN\\|LOCATIONIQ_API_KEY\" .env\n 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 # Try different provider order\nGEOCODING_PROVIDERS=GOOGLE,NOMINATIM,PHOTON,MAPBOX,LOCATIONIQ,ARCGIS\n 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:
/data directorySolutions:
# 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 10 = Newfoundland and Labrador\n24 = Quebec\n35 = Ontario\n48 = Alberta\n59 = British Columbia\n62 = Nunavut\n # Verify proj4 is installed\ndocker compose exec api node -e \"const proj4 = require('proj4'); console.log(proj4.version);\"\n # 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 Use smaller filters for testing:
Start with single postal code prefix (e.g., \"K1A\")
Symptoms:
Causes:
Solutions:
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 -- 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 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:
NAR Location files have TWO coordinate fields:
BG_LATITUDE / BG_LONGITUDE: Direct WGS84 (use these if available)BG_X / BG_Y: Lambert Conformal Conic EPSG:3347 (requires conversion)
Use BG_LATITUDE/BG_LONGITUDE if available:
// 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 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/secBest Practices:
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:
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:
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:
Return available datasets
Import Initiation:
Begins streaming CSV files
File Processing:
Join on LOC_GUID (in-memory map)
Coordinate Conversion:
Fallback to BG_LATITUDE/BG_LONGITUDE if conversion fails
Filtering:
Residential filter (BU_USE = 1)
Database Import:
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
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
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
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:
Verify NAR_DATA_DIR path:
echo $NAR_DATA_DIR\nls -la /data\n Check Docker volume mount:
# docker-compose.yml\nservices:\n api:\n volumes:\n - ./data:/data:ro\n 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 Check file permissions:
chmod 644 /data/Address_*.csv\nchmod 644 /data/Location_*.csv\n Symptoms: - Many locations skipped during import - \"Converted coordinates outside Canada\" warnings - Null latitude/longitude in database
Solutions:
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 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 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 Check proj4 definition:
npm list proj4\n# Ensure version 2.8.0+\n Symptoms: - Import hangs on large provinces - Memory usage grows over time - Database connection timeouts
Solutions:
Increase batch size:
NAR_BATCH_SIZE=1000 # Default: 500\n 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 Optimize database indexes:
CREATE INDEX CONCURRENTLY idx_locations_loc_guid ON \"Location\"(locGuid);\nCREATE INDEX CONCURRENTLY idx_addresses_addr_guid ON \"Address\"(addrGuid);\n Disable geocoding during import:
// Skip geocoding service since NAR already has coordinates\ngeocodeConfidence: 100,\ngeocodeProvider: 'NAR'\n// No call to geocodingService.geocode()\n 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 Symptoms: - Unique constraint violation on locGuid - Import fails mid-process - \"Duplicate key value violates unique constraint\" error
Solutions:
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 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 Clean up partial imports:
-- Delete locations from failed import\nDELETE FROM \"Location\" WHERE \"geocodeProvider\" = 'NAR' AND \"createdAt\" > '2025-02-13';\n 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 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,379Factors: - 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":"api/src/modules/map/locations/nar-import.service.tsBatch import
NAR Import Routes: api/src/modules/map/locations/nar-import.routes.ts
Progress tracking
Locations Service: api/src/modules/map/locations/locations.service.ts
admin/src/pages/LocationsPage.tsxapi/prisma/schema.prismaFederal district index
Address Model: api/prisma/schema.prisma
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:
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:
See Shift Model Documentation for full schema.
Key Fields:
title: Shift name (e.g., \"Saturday Canvassing - Downtown\")description: Free-text shift detailsdate: Shift date (Date type, not DateTime)startTime: Start time in HH:MM format (24-hour)endTime: End time in HH:MM format (24-hour)location: Meeting point address/descriptionmaxVolunteers: Maximum volunteer capacitycurrentVolunteers: Current signup count (auto-updated)status: OPEN | FULL | CANCELLEDisPublic: Show on public shifts pagecutId: Optional foreign key to Cut (territory assignment)createdBy: User ID who created shiftStatus 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:
shiftId: Foreign key to ShiftuserId: Foreign key to User (optional for TEMP users)userEmail: Email address (required, used for confirmations)userName: Display nameuserPhone: Phone number (optional)status: CONFIRMED | CANCELLED | NO_SHOWsignupDate: When signup occurredsignupSource: AUTHENTICATED | PUBLIC | ADMINnotes: Admin notes about signupSignup 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:
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:
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:
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:
// 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:
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 -- 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 // 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:
# 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 # 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 # 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 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:
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 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:
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:
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:
See TrackingSession Model Documentation.
Key Fields:
userId: Foreign key to volunteer UsercanvassSessionId: 1:1 foreign key to CanvassSessionstartedAt: Tracking start timestampendedAt: Tracking end timestamp (null while active)isActive: Boolean - tracking currently runningtotalPoints: Count of TrackPoint recordstotalDistanceM: Total distance walked in meterslastLatitude / lastLongitude: Most recent GPS positionlastRecordedAt: Timestamp of last GPS pointSee TrackPoint Model Documentation.
Key Fields:
trackingSessionId: Foreign key to TrackingSessionlatitude / longitude: GPS coordinates (Decimal type)accuracy: GPS accuracy in meters (lower = better)recordedAt: When point was recorded (client timestamp)eventType: Optional event marker (LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED)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":"// 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:
enableHighAccuracy: falseSUBMIT_INTERVAL_MS = 30000 (30s)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":"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:
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:
QR code URLs and labels defined (up to 3)
Generation Phase:
Walk sheet rendered with all components
Print Phase:
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 labelQR 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
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
Step 1: Navigate to Walk Sheet Page
Step 2: Select Cut
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
Step 5: Distribute to Volunteers
Step 1: Navigate to Cuts Page
Step 2: Open Cut Export
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
cut-42-downtown-core-2026-02-13.csvCSV 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/AMulti-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:
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 decimalTable Features:
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:
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 Check qrcode package installed:
cd api\nnpm list qrcode\n# If not installed:\nnpm install qrcode\nnpm install --save-dev @types/qrcode\n Verify route registration in server.ts:
import qrRoutes from './modules/qr/qr.routes';\napp.use('/api/qr', qrRoutes);\n 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 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 Symptoms: - Elements overlap when printing - Missing borders or backgrounds - Incorrect page breaks - Cut-off content
Solutions:
Safari: Print \u2192 Show Details \u2192 Print backgrounds (checked)
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 Check @page margins:
@media print {\n @page {\n size: A4 portrait;\n margin: 0.5in; /* Adjust if content cut off */\n }\n}\n 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 Test in different browsers:
Safari: May require webkit prefixes
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 Symptoms: - Selected cut shows different locations - Location count doesn't match cut - Locations outside cut boundary visible
Solutions:
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 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 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 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 Check cut geojson validity:
[lng, lat] orderSymptoms: - Walk sheet takes > 10 seconds to load - Browser freezes during render - Print preview crashes
Solutions:
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 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 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 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 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 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:
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 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 Debounce cut selection:
import { debounce } from 'lodash';\n\nconst debouncedFetchLocations = debounce((cutId: number) => {\n fetchLocations(cutId);\n}, 300);\n\n<Select onChange={debouncedFetchLocations} />\n 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
api/src/modules/qr/qr.routes.tsSize validation (50-500px)
Map Settings: api/src/modules/map/settings/
QR code URL storage
Cuts API: api/src/modules/map/cuts/
Point-in-polygon filtering
Locations API: api/src/modules/map/locations/
admin/src/pages/WalkSheetPage.tsxPrint functionality
Cut Export Page: admin/src/pages/CutExportPage.tsx
Print layout
Map Settings Page: admin/src/pages/MapSettingsPage.tsx
api/prisma/schema.prismaSize/margin options
CSS Print Media: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/print
page-break properties
GeoJSON Specification: https://geojson.org/
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:
Six emoji reactions:
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)
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
# 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":"File type validation
Upload to Inbox
/media/local/inboxProgress tracking
Extract Metadata
Audio detection
Create Database Record
Link to user
Process Video (Future)
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":"Videos can be shared publicly:
/mediaPredefined categories:
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":"Background jobs for:
Admin can:
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":"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:
SUPER_ADMIN roleTechnology Stack:
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:
// 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.
GET /api/media/jobs\n Query Parameters:
Parameter Type Default Descriptionpage 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:
maxRetries times (default: 3)2^retryCount minutes before retrypending and appends retry marker to logPOST /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:
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":"Option 1: From Library Page
Option 2: From Jobs Page
Real-Time Updates:
Detailed Logs:
[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":"Auto-Retry:
Jobs automatically retry up to 3 times with exponential backoff:
Use Case: Temporarily stop low-priority jobs to free resources for urgent tasks
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:
/media/local/library/{directoryType}/.mp4, .mov, etc.)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:
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:
reencodeJobId referenceTypical 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:
count videosQuad 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):
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:
thumbnailPathTypical 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:
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 # Restart media-api container\ndocker compose restart media-api\n\n# Worker starts automatically on container boot\n docker compose logs -f media-api | grep ERROR\n# Look for database connection errors, permission issues\n # 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:
SELECT log FROM jobs WHERE id = 'JOB_ID';\n docker compose exec media-api which ffmpeg\n# Should output: /usr/bin/ffmpeg\n\ndocker compose exec media-api ffmpeg -version\n # 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 # 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:
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 docker compose exec media-api pkill -9 ffmpeg\n\n# Job will fail and can be retried\n 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 // 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:
nvidia-smi\n# Shows GPU memory usage\n\n# Should show < 16GB used (adjust based on your GPU)\n // 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 // 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 # 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:
Optimization:
Job Queue Index:
CREATE INDEX idx_jobs_status_priority ON jobs(status, priority, created_at);\n Query Performance:
Optimization:
// 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":"backend/modules/media/job-worker.md \u2014 Worker process implementationbackend/modules/media/processors/ \u2014 Individual job type processors (reencode, scan, etc.)backend/modules/media/jobs.md \u2014 API endpoints for job managementfrontend/pages/media/jobs.md \u2014 Job queue monitoring UIfrontend/components/media/job-detail.md \u2014 Log viewer componentfeatures/media/video-library.md \u2014 Triggering jobs from library actionsfeatures/media/upload.md \u2014 Post-upload job creationAfter mastering the job queue:
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:
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:
// 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.
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:
like \u2014 General approvallove \u2014 Strong positive emotionlaugh \u2014 Funny/amusingsurprise \u2014 Surprising/shockingsad \u2014 Sad/emotionalangry \u2014 Frustrating/angeringSession 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:
approved = falseapproved = true, comment visibleCREATE 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 Descriptionpage 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:
publicViewCountPOST /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:
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:
movedFromPublicAt timestamp set (preserves history)Shared Media Page Features:
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
Per-Video Stats:
Gallery-Wide Stats:
Dashboard widget showing:
Bulk Moderation:
Infinite Scroll:
Video Player Features:
Reaction Colors:
Comment Formatting:
// 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:
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 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 # Clear public video cache\ndocker compose exec redis redis-cli\n> KEYS public:videos:*\n> DEL public:videos:*\n\n# Refresh gallery page\n 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:
// 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 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 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:
approved = trueSolutions:
// 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 # Video details may be cached\ndocker compose exec redis redis-cli DEL \"public:video:VIDEO_ID\"\n 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:
public:videos:{query} \u2014 List of videos (5 min TTL)public:video:{id} \u2014 Video details (10 min TTL)public:stats \u2014 Gallery-wide stats (15 min TTL)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:
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:
/media/local/library/...)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":"backend/modules/media/public.md \u2014 Public API endpointsbackend/modules/media/reactions.md \u2014 Reaction system implementationbackend/modules/media/comments.md \u2014 Comment moderation systemfrontend/pages/public/media-gallery.md \u2014 Gallery UI implementationfrontend/pages/public/media-viewer.md \u2014 Player componentfeatures/media/video-library.md \u2014 Admin video managementfeatures/media/shared-media.md \u2014 Sharing controls (admin)After mastering the public gallery:
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:
/inbox directory before processingTechnology Stack:
/media/local/inbox directorysequenceDiagram\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:
Key Design Decisions:
/inbox directory instead of final location, allowing admin review before publishing../../etc/passwd.mp4)Modal opens with drag-drop zone
Select Files
Multiple files can be selected for batch upload
Review Queue
Invalid files (wrong extension, too large) highlighted in red
Enter Metadata (Optional)
Tags \u2014 Comma-separated tags (e.g., \"action, sports, highlight\")
Upload
Progress bar shows:
Metadata Extraction
Auto-fills: duration, dimensions, orientation, quality, audio
Success
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.
// 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:
# 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 // api/src/media-server.ts\napp.register(multipart, {\n limits: {\n fileSize: 10 * 1024 * 1024 * 1024, // 10GB\n },\n});\n # nginx/nginx.conf or nginx/conf.d/api.conf\nclient_max_body_size 10G;\n # 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:
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 # 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 # 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 # 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 # .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:
# 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 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 docker compose logs -f media-api | grep upload\n# Look for errors or timeouts\n # 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:
# docker-compose.yml\nservices:\n media-api:\n volumes:\n - /media/local/inbox:/media/local/inbox:rw # MUST have :rw suffix\n docker compose exec media-api mount | grep inbox\n# Should show /media/local/inbox mounted as rw (read-write)\n # 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 # On host\nsudo mkdir -p /media/local/inbox\nsudo chmod 777 /media/local/inbox\n\n# Restart container\ndocker compose restart media-api\n # 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:
// Browser console\nconst file = document.querySelector('input[type=file]').files[0];\nconsole.log(file.type);\n// Should be video/mp4, video/quicktime, etc.\n # Rename file to ensure correct extension\nmv video.MP4 video.mp4 # Case-sensitive on Linux\n // 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 // Temporarily comment out beforeUpload validation\nbeforeUpload={() => false}\n // 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:
// 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 # Use /api/media/upload/batch for multiple files\n# Not multiple calls to /api/media/upload/single\n // api/src/media-server.ts\napp.register(multipart, {\n limits: {\n files: 10, // Max 10 files per request\n },\n});\n // 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 minutesOptimization:
# 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 Mount /media/local/inbox on SSD instead of HDD.
# 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-1sOptimization:
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 GBWhy 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:
.exe.sh.bat.php.js (only video extensions)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":"backend/modules/media/upload.md \u2014 Upload endpoint implementationbackend/modules/media/ffprobe.md \u2014 Metadata extraction servicebackend/api/media-server.md \u2014 Multipart plugin configurationfrontend/components/media/upload-modal.md \u2014 Upload UI componentfrontend/pages/media/library.md \u2014 Integration with library tablefeatures/media/video-library.md \u2014 Video management system overviewfeatures/media/jobs.md \u2014 Background processing for uploadsdeployment/docker.md \u2014 Volume mount configuration for inboxdeployment/nginx.md \u2014 Reverse proxy upload timeout settingsAfter mastering video upload:
/inbox to target directoriespublic-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:
SUPER_ADMIN rolepublic-gallery.md)Technology Stack:
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:
/media/local/librarymedia.cmlite.org routes to port 4100Why Dual API?
The media system was added after V2 launch as a self-contained enhancement. Keeping it as a separate Fastify microservice:
// 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.
GET /api/media/videos\n Query Parameters:
Parameter Type Default Descriptionpage 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:
/api/media/upload/single for file upload + record creationResponse:
{\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:
producer \u2014 Video producer/studiocreator \u2014 Director/creator nametitle \u2014 Display titletags \u2014 Array of tag stringsthumbnailPath \u2014 Custom thumbnail pathResponse:
{\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:
isValid = false instead of removing recordisValid = true via databaseResponse:
{\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 DescriptiondirectoryType string \u2705 Directory to scan (videos, studios, etc.) skipExisting boolean - Skip files already in database (default: true) Process:
/media/local/library/{directoryType}/.mp4, .mov, .avi, .mkv, .webm, .m4v, .flv)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.
The media system respects the global ENABLE_MEDIA_FEATURES flag in Site Settings:
SELECT * FROM settings WHERE key = 'ENABLE_MEDIA_FEATURES';\n When disabled:
When to Use:
Steps:
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":"Bulk Editing:
Purpose: Refresh metadata and verify file integrity
Steps:
lastValidated timestamp updatesisValid set to falseBulk Validation:
Soft Delete (Default):
isValid = falseViewing Deleted Videos:
Hard Delete (Database Only):
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:
// 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:
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 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 # 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 # 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 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:
# 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 # 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 # 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 Only these extensions scanned:
.mp4.mov.avi.mkv.webm.m4v.flvRename 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 # 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:
isValid = falseSolutions:
# 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 # 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 # 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 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 # 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 # 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:
# Drizzle uses push (not migrations)\ncd api\nnpx drizzle-kit push\n\n# Confirm changes\n # 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 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:
-- 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 // admin/src/pages/media/LibraryPage.tsx\nconst [pagination, setPagination] = useState({ page: 1, limit: 10, total: 0 });\n// Reduced from 20 to 10\n // 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 // 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:
skipExisting: true to only process new filesTiming:
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:
SELECT * for large tablesDeferred Loading:
Don't generate thumbnails during scan. Instead:
thumbnailPathLazy 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:
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:
Short Term (Current):
Medium Term (6-12 months):
Long Term (12+ months):
Migration Effort Estimate:
backend/api/media-server.md \u2014 Fastify server setup, middleware, error handlingbackend/modules/media/videos.md \u2014 Video routes, service layer, business logicbackend/modules/media/ffprobe.md \u2014 Metadata extraction implementationbackend/modules/media/jobs.md \u2014 Job queue architecture, worker processesfrontend/pages/media/library.md \u2014 Video library management UIfrontend/pages/media/shared.md \u2014 Public gallery admin UIfrontend/components/media.md \u2014 Reusable video componentsdatabase/models/media.md \u2014 Drizzle schema definitions for videos, compilations, jobsdatabase/drizzle.md \u2014 Drizzle ORM configuration, connection managementfeatures/media/upload.md \u2014 Upload system workflow, FFprobe integrationfeatures/media/jobs.md \u2014 Job queue system, processing pipelinefeatures/media/public-gallery.md \u2014 Public video sharing systemarchitecture/dual-api.md \u2014 Express+Prisma vs Fastify+Drizzle comparisondeployment/nginx.md \u2014 Reverse proxy configuration for media.cmlite.orgdeployment/docker.md \u2014 Media API container, volume mounts, healthchecksAfter mastering video library management:
features/media/upload.md to understand video upload workflowfeatures/media/jobs.md for video processing automationfeatures/media/public-gallery.md for sharing videos publiclyFor 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:
LISTMONK_SYNC_ENABLED flagAutomatically sync users to Listmonk:
Auto-create and manage lists:
Automatic sync on:
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
# 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":"/api/subscribersenabled/api/listspublic or private/api/subscribers/:id/listsSUPER_ADMIN, INFLUENCE_ADMIN, etc.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":"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":"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":"LISTMONK_SYNC_ENABLED=trueLISTMONK_API_URL reachableThe 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:
cm_* metricsCustom 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:
Error rates
Canvassing Metrics - Canvass-specific metrics
Volunteer leaderboard
External Services - Integration health
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:
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
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
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":"/app/observability \u2192 Dashboards tabConfigure 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:
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:
wget --spider http://localhost:4000/healthwget --spider http://localhost:4100/healthpg_isreadyredis-cli pingwget --spider http://localhost:9000/healthAutomatic tracking of:
Track queue depths:
Via cAdvisor and Node Exporter:
Display cards:
Embedded Grafana:
Active alerts list:
# 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":"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":"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:
api/prisma/seed.tsGET /api/page-blocksSchema:
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 Descriptionid 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:
type (unique)category + sortOrder (composite, for sorted listing)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:
category (string?) \u2014 Filter by categoryResponse:
[\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:
category ASC, sortOrder ASCGET /api/page-blocks/:id\n Response: Single PageBlock object
Errors:
404 BLOCK_NOT_FOUND \u2014 Block doesn't existPOST /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:
type (string, required) \u2014 Unique type identifier (alphanumeric + hyphens)label (string, required) \u2014 Display namecategory (string?) \u2014 Group name (default: null)sortOrder (number?, default: 0) \u2014 Position in listschema (JSON, required) \u2014 Property definitionsdefaults (JSON, required) \u2014 Default values matching schemathumbnail (string?) \u2014 Preview image URLResponse: Created PageBlock object (201 status)
Errors:
400 VALIDATION_ERROR \u2014 Invalid schema or type collisionPUT /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)
type (string?) \u2014 Cannot change after creation (immutable)label (string?)category (string?)sortOrder (number?)schema (JSON?)defaults (JSON?)Response: Updated PageBlock object
Errors:
404 BLOCK_NOT_FOUND \u2014 Block doesn't exist400 VALIDATION_ERROR \u2014 Invalid schema or defaultsDELETE /api/page-blocks/:id\n Response: 204 No Content
Errors:
404 BLOCK_NOT_FOUND \u2014 Block doesn't existSide Effects:
Supported Types:
Type Description Examplestring 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:
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":"\"Join the Movement\"\"Together we can make a difference.\"\"Sign Up\"\"/shifts\"Ctrl+S \u2192 Block HTML stored in databaseNote: 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:
generateBlockHtml update)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:
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:
generateBlockHtml() missing caseSolutions:
Blocks fetched on mount
Add HTML generation case:
case 'my-new-block':\n return `<section>My block HTML</section>`;\n Check category:
SELECT category FROM page_blocks WHERE type = 'my-new-block';\n-- Category should match GrapesJS panel (case-sensitive)\n Verify API response:
curl -H \"Authorization: Bearer $TOKEN\" http://localhost:4000/api/page-blocks\n# Should include new block in response\n Symptoms:
Causes:
Solutions:
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 Check HTML template:
// Good - uses defaults\nreturn `<h1>${defaults.title || 'Fallback'}</h1>`;\n\n// Bad - ignores defaults\nreturn `<h1>Hardcoded Title</h1>`;\n Fix type mismatch:
// If schema says \"number\", defaults must be number\n{ \"count\": { \"type\": \"number\" } }\n{ \"count\": 42 } // Good\n{ \"count\": \"42\" } // Bad\n Symptoms:
Causes:
generateBlockHtml() returns invalid HTMLSolutions:
Validate HTML:
const html = generateBlockHtml('my-block', defaults);\nconsole.log(html); // Check for malformed tags\n Test inline styles:
<!-- Bad - missing quotes -->\n<div style=padding: 20px>\n\n<!-- Good - quoted attribute -->\n<div style=\"padding: 20px;\">\n Use template literals carefully:
// Ensure all ${} expressions return strings\nreturn `<div>${defaults.title || ''}</div>`;\n Threshold: 50+ blocks in library
Symptoms:
Mitigations:
Lazy-load categories on expand
Pagination:
Not implemented in current version
Caching:
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:
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>
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":"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:
grapesjs.init() \u2192 Loads pluginsinitialData (GrapesJS projectData JSON)useImperativeHandle exposes triggerSave() methodeditorRef.current.triggerSave() \u2192 Runs save-page commandonSave() \u2192 Parent saves to APIinterface GrapesJSEditorProps {\n initialData?: Record<string, unknown>;\n onSave: (data: { projectData: Record<string, unknown>; html: string; css: string }) => void;\n customBlocks?: PageBlock[];\n}\n Fields:
initialData (optional): GrapesJS projectData JSON from previous save{} for new pagesonSave (required): Callback when save triggered{ projectData, html, css }customBlocks (optional): Array of PageBlock records from databaseinterface GrapesJSEditorHandle {\n triggerSave: () => void;\n}\n Method:
triggerSave(): Programmatically trigger save commandonSave callbackimport { 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:
storageManager: false: Disables auto-save to localStorage (we use API persistence)height: '100%': Fills parent container (full-screen editor)canvas.styles: Injects Google Fonts into preview iframegrapesjs-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?
onSave propconst onSaveRef = useRef(onSave); onSaveRef.current = onSave;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:
Ctrl+SCmd+SBehavior:
onSave callback with current stateconst 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?
onManualSave callbackconst [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:
init()initialData crashes tabRecovery:
// 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:
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:
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:
blocks field contains full GrapesJS projectData (components tree, styles, assets){} for new pages (GrapesJS shows blank canvas)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:
getProjectData() returns full editor state (for future edits)getHtml() returns rendered HTML (for public display)getCss() returns compiled CSS (for public display)// 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:
generateBlockHtml() missing case for block typeSolutions:
Add case to generateBlockHtml():
case 'my-custom-block':\n return `<section>My custom block HTML</section>`;\n Check category:
// Block category: \"Campaign\"\n// GrapesJS shows blocks in collapsible \"Campaign\" section\n// Case-sensitive match\n Verify registration timing:
// Registration happens in useEffect after init\nconsole.log('Registering blocks:', customBlocks.length);\n 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 Symptoms:
onSave callback never calledCauses:
save-page command not registeredSolutions:
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 Verify ref handle:
// In parent component\nconsole.log('Editor ref:', editorRef.current); // Should be { triggerSave: fn }\n Test command directly:
// In browser console (after editor loads)\nwindow.editor.runCommand('save-page');\n// Should trigger onSave callback\n Check onSaveRef pattern:
const onSaveRef = useRef(onSave);\nonSaveRef.current = onSave; // Update on every render\n Symptoms:
Causes:
Solutions:
Link pages via navigation
Use CODE mode for complex layouts:
Import via \"Sync Overrides\"
Optimize images:
Lazy load below fold
Increase browser memory:
--max-old-space-size=4096Symptoms:
initialData prop has dataCauses:
loadProjectData() called before editor readySolutions:
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 Validate JSON:
console.log('Loading data:', JSON.stringify(initialData, null, 2));\n// Should have keys: assets, styles, pages\n 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 Symptoms:
Causes:
Solutions:
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 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 Check iframe sandbox:
// GrapesJS canvas uses <iframe> \u2014 ensure no sandbox restrictions\n// Default config works, but custom CSP may block\n // 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":"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":"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:
pages.service.update() checks publish statusexportToMkDocs() with page data{% extends \"main.html\" %}mkdocs/docs/overrides/{slug}.html \u2014 HTML overridemkdocs/docs/{slug}.md \u2014 Markdown stubmkdocs build)https://cmlite.org/pages/{slug}/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:
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:
lander.html)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: 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 Descriptiontemplate 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).
Fields:
Field Type Default DescriptionmkdocsPath 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:
published=true):mkdocsSkipExport=false: Export filesmkdocsSkipExport=true: No export (page only at /p/:slug)published=false): Remove export filesmkdocsPath, clean up old filesAutomatic Export (on publish):
published=truemkdocsSkipExportfalse: Calls exportToMkDocs()mkdocsStubPathManual Export Trigger:
mkdocsExportMode or mkdocsHideNavLocation: Page Settings modal \u2192 MkDocs Integration section
Steps:
about.html (auto-filled)Trigger: After exporting pages
Methods:
Option 1: Admin UI
docker compose exec mkdocs mkdocs buildOption 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:
.html file in mkdocs/docs/overrides/custom.htmlhtmlOutput from disk.md stubsSynced: 2 imported, 1 updated, 3 stubs createdUse Cases:
Purpose: Verify files exist on disk, repair if missing
Workflow:
.html override exists.md stub existsValidated 10 pages: 2 repaired, 0 errorsUse Cases:
// 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, '"')}\">`\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, '<')}</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:
custom_dir: overrides \u2014 Enables template overridesnav to appear in navigationMkDocs Material searches:
mkdocs/overrides/ (custom_dir)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/.
Symptoms:
jinja2.exceptions.TemplateNotFound: overrides/about-us.htmlCauses:
template: overrides/about-us.html (incorrect path)custom_dir not configured in mkdocs.ymlSolutions:
Fix stub front matter:
# Before (wrong)\ntemplate: overrides/about-us.html\n\n# After (correct)\ntemplate: about-us.html\n Verify custom_dir:
# In mkdocs.yml\ntheme:\n name: material\n custom_dir: overrides\n Check file exists:
ls -la mkdocs/docs/overrides/about-us.html\n# Should exist if page published\n Validate exports:
Symptoms:
docker compose restart: Files goneCauses:
Solutions:
Check volume mount:
# In docker-compose.yml\nservices:\n api:\n volumes:\n - ./mkdocs:/mkdocs:rw # Must have :rw for write access\n Verify host files:
ls -la mkdocs/docs/overrides/\n# Files should persist on host filesystem\n Re-export all pages:
Symptoms:
Causes:
mkdocs.yml navSolutions:
Add to nav (optional):
nav:\n - Pages:\n - About: about-us.md # Stub filename\n Rebuild MkDocs:
docker compose exec mkdocs mkdocs build\n# Or Admin \u2192 Pages \u2192 \"Build Site\"\n Clear Nginx cache:
docker compose exec nginx nginx -s reload\n Test direct access:
curl http://localhost:4001/pages/about-us/\n# Should return HTML, not 404\n Symptoms:
Causes:
cssOutput)Solutions:
Check cssOutput field:
SELECT css_output FROM landing_pages WHERE slug = 'about-us';\n-- Should contain CSS, not NULL\n Inspect rendered HTML:
curl http://localhost:4001/pages/about-us/ | grep '<style>'\n# Should include page CSS\n Use !important for overrides:
/* In page CSS */\nsection {\n padding: 40px !important;\n}\n Test STANDALONE mode:
Symptoms:
mkdocsHideNav=trueCauses:
Solutions:
Check stub front matter:
cat mkdocs/docs/about-us.md\n# Should have:\n# hide:\n# - navigation\n Re-export:
Triggers stub regeneration
Clear MkDocs cache:
rm -rf mkdocs/site/\ndocker compose exec mkdocs mkdocs build\n Verify not STANDALONE:
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:
mkdocs serve --dirtyreload in dev (incremental builds)Validation:
about\\0.html attackspath.normalize() resolves ..//etc/passwd.html%2e%2e/admin.html.htmlRejected Paths:
../../../etc/passwd.html/var/www/config.htmladmin%2e%2e%2fconfig.htmlabout.md (wrong extension)Docker Volume Mount:
volumes:\n - ./mkdocs:/mkdocs:rw\n Permissions:
node user (UID 1000)mkdocs/docs/mkdocs user (UID 1001)Risk: Container escape could write arbitrary files
Mitigation:
/mkdocs only (no host root access)Risk: Malicious admin injects Jinja2 code
Example:
<!-- Malicious HTML in editor -->\n<h1>{{ config.site_name }}</h1>\n Rendering:
{{ }} expressionsMitigation:
exportToMkDocs, validateExports, syncOverrides)/sync and /validate endpointsComplete 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":"/p/:slug (e.g., /p/about-us)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:
projectData (GrapesJS JSON), htmlOutput, cssOutput.html override + .md stub to MkDocs/p/:slug (React route renders HTML)Table: landing_pages
Key Fields:
Field Type Descriptionid 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:
slug (unique)published (filter index)Relationships:
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:
page (number, default: 1) \u2014 Page numberlimit (number, default: 20, max: 100) \u2014 Results per pagesearch (string?) \u2014 Search title, description, or slug (case-insensitive)published (string?) \u2014 Filter by status: \"true\", \"false\", or omit for allResponse:
{\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:
404 PAGE_NOT_FOUND \u2014 Page doesn't existPOST /api/pages\nContent-Type: application/json\n\n{\n \"title\": \"New Landing Page\",\n \"description\": \"Page description\",\n \"editorMode\": \"VISUAL\"\n}\n Request Body:
title (string, required) \u2014 Page title (slug auto-generated)description (string?) \u2014 Internal descriptioneditorMode (enum?, default: VISUAL) \u2014 VISUAL or CODEmkdocsPath (string?) \u2014 Custom override path (defaults to {slug}.html)Response: Created LandingPage object (201 status)
Errors:
400 INVALID_MKDOCS_PATH \u2014 Invalid path (traversal attempt, missing .html extension)Behavior:
-2, -3, etc.)blocks initialized as empty JSON objectpublished defaults to falsePUT /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)
title (string?) \u2014 New title (regenerates slug if changed)description (string?)blocks (JSON?) \u2014 GrapesJS projectDatahtmlOutput (string?) \u2014 Rendered HTMLcssOutput (string?) \u2014 Rendered CSSpublished (boolean?) \u2014 Publish statusmkdocsPath (string?) \u2014 Custom override pathmkdocsExportMode (enum?) \u2014 THEMED or STANDALONEmkdocsHideNav (boolean?)mkdocsHideToc (boolean?)mkdocsSkipExport (boolean?)seoTitle (string?)seoDescription (string?)seoImage (string?)Response: Updated LandingPage object
Errors:
404 PAGE_NOT_FOUND \u2014 Page doesn't exist400 INVALID_MKDOCS_PATH \u2014 Invalid pathSide Effects:
.html + .md stub)mkdocsPath if it was auto-generated, cleans up old exportsDELETE /api/pages/:id\n Response: 204 No Content
Errors:
404 PAGE_NOT_FOUND \u2014 Page doesn't existSide Effects:
.html override + .md stub) if they existPOST /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:
mkdocs/docs/overrides/ recursively for .html fileshtmlOutput from disk (disk wins).md stubs for published pagesUse Cases:
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:
mkdocsPath.html override and .md stub existmkdocsStubPath if changedUse Cases:
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:
404 PAGE_NOT_FOUND \u2014 Page doesn't exist or is unpublishedSecurity:
published=true)blocks, mkdocsPath, etc.)# 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":"\"About Us\" (slug auto-generated: about-us)\"Learn about our campaign\" (optional)VISUAL (default) or CODECtrl+S (or Cmd+S on Mac) \u2192 API saves projectData, htmlOutput, cssOutputCtrl+S \u2192 API saves htmlOutput, cssOutputOption 1: From Table
/p/{slug}Option 2: From Settings Modal
Side Effects (on publish):
mkdocsSkipExport=false: Exports .html + .md to MkDocsmkdocsSkipExport=true: Only accessible via /p/:slug (no MkDocs export)<title> and Open Graph (defaults to title)https://cdn.example.com/og.jpg)Access: Page Settings modal \u2192 MkDocs Integration section
Fields:
/p/:slug (not documentation)Default: false (export enabled)
Override Path (text input)
custom-about.html){slug}.html)Validation: Must end with .html, no path traversal
Full page MkDocs (checkbox)
<!DOCTYPE html> document){% extends \"main.html\" %})false (THEMED)Use case: Standalone pages with no MkDocs chrome (like lander.html)
Hide navigation sidebar (checkbox, only for THEMED mode)
hide: [navigation] to .md stub front matterDefault: false
Hide table of contents (checkbox, only for THEMED mode)
hide: [toc] to .md stub front matterfalseWorkflow:
Purpose: Import hand-coded .html files from disk
Workflow:
.html files in mkdocs/docs/overrides/ (on Docker host)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:
.html override exists?.md stub exists?Validated 10 pages: 2 repairedUse Cases:
https://yoursite.com/p/about-us/p/:slug route \u2192 Loads LandingPage.tsxGET /api/pages/about-us/viewhtmlOutput, cssOutput, SEO fieldsdocument.title = seoTitle || titlecssOutput as <style> taghtmlOutput via dangerouslySetInnerHTML.video-block divs, replaces placeholders with React VideoPlayer componentsApplied 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:
LandingPage.tsx mounts \u2192 Scans for .video-block elementsdata-* attributesAdvancedVideoPlayer or VideoPlayer componentSupported Attributes:
data-video-id (required) \u2014 Media library video IDdata-player-type (\"standard\" or \"advanced\", default: \"standard\")data-width (CSS value, default: \"100%\")data-height (CSS value, default: \"auto\")data-autoplay (\"true\" or \"false\", default: \"false\")data-controls (\"true\" or \"false\", default: \"true\")data-show-reactions (\"true\" or \"false\", default: \"true\", advanced player only)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:
Cannot read property 'init' of undefinedCauses:
Solutions:
Verify installation:
cd admin && npm list grapesjs\n# Should show: grapesjs@0.21.x\n Check CSS import:
// In GrapesJSEditor.tsx\nimport 'grapesjs/dist/css/grapes.min.css';\n Check browser console:
grapesjs variable in global scopeVerify all plugins loaded successfully
Clear cache:
# In browser DevTools\n# Right-click Reload \u2192 Empty Cache and Hard Reload\n Symptoms:
/p/my-pagepublished=trueCauses:
Solutions:
Verify route registration:
// In admin/src/App.tsx\n<Route path=\"/p/:slug\" element={<LandingPage />} />\n Check slug in URL:
/p/About-Us \u2260 /p/about-usUse lowercase, hyphenated: /p/about-us
Test API directly:
curl http://localhost:4000/api/pages/about-us/view\n# Should return JSON, not 404\n Check published status:
SELECT slug, published FROM landing_pages WHERE slug = 'about-us';\n-- published should be true\n Symptoms:
Causes:
Solutions:
Check actual viewport width:
// In browser console\nconsole.log(window.innerWidth);\n// Should be > 768 for desktop\n Undock DevTools:
Increases available viewport width
Verify breakpoint hook:
// In PageEditorPage.tsx\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md; // md = 768px\n Test responsive mode:
Symptoms:
/pages/about-us/mkdocs/docs/overrides/Causes:
mkdocsSkipExport=trueSolutions:
Verify publish status:
SELECT slug, published, mkdocs_skip_export FROM landing_pages WHERE slug = 'about-us';\n-- Both should be true/false appropriately\n Check export path:
ls -la mkdocs/docs/overrides/about.html\n# Should exist if published and not skipped\n Validate exports:
Check repair count
Rebuild MkDocs:
docker compose exec mkdocs mkdocs build\n# Or in admin: Pages \u2192 \"Build Site\"\n Check template path in stub:
cat mkdocs/docs/about.md\n# Should show: template: about.html (NOT overrides/about.html)\n Symptoms:
about-us-2about-us but already takenCauses:
Solutions:
Check existing pages:
SELECT id, title, slug, published FROM landing_pages WHERE slug LIKE 'about-us%';\n Delete duplicate:
New page can reuse slug
Use unique title:
Rename new page: \"About Us 2026\" \u2192 slug about-us-2026
Manual slug override:
about-us-custom.htmlSymptoms:
Invalid video ID: PLACEHOLDERCauses:
data-video-id=\"PLACEHOLDER\" not replacedSolutions:
123)Not PLACEHOLDER
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 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 Test video ID validity:
curl http://localhost:4100/api/media/videos/42\n# Should return video metadata, not 404\n GrapesJS startup: ~500ms on modern desktop
Optimization strategies:
const GrapesJS = lazy(() => import('./GrapesJSEditor'))Complexity threshold: 100+ components
Symptoms:
Mitigations:
Database overhead: htmlOutput can be 50KB+ for complex pages
Considerations:
published for public queries (fast)React hydration: Video blocks hydrate after initial render (~100ms delay)
Performance tips:
dangerouslySetInnerHTML for immediate HTML paintsetTimeout(..., 100)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
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
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:
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:
Map internal services to public subdomains:
app.yoursite.com \u2192 Admin GUI (port 3000)api.yoursite.com \u2192 Express API (port 4000)media.yoursite.com \u2192 Media API (port 4100)docs.yoursite.com \u2192 MkDocs (port 4003)grafana.yoursite.com \u2192 Grafana (port 3001)Setup wizard (/app/services/pangolin):
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
Admin Page: - admin/src/pages/PangolinPage.tsx - Setup wizard - Step-by-step configuration - Status dashboard - Resource table
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":"PANGOLIN_API_KEYimport { 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":"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":"Newt is the exit node that:
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:
NEWT_ID - Unique container identifierNEWT_SECRET - Authentication secretPANGOLIN_ENDPOINT - Tunnel endpoint URLinterface 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":"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":"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:
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:
Reusable UI components organized by feature:
42 page components across three sections:
Auth Store (stores/auth.store.ts)
Canvass Store (stores/canvass.store.ts)
Main API Client (lib/api.ts)
Media API Client (lib/media-api.ts)
Public API Client (lib/media-public-api.ts)
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.
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:
components/AppLayout.tsxDark theme layout for public pages:
components/PublicLayout.tsxTop navigation layout for volunteer portal:
components/VolunteerLayout.tsxMinimal layout for public media gallery:
components/MediaPublicLayout.tsxFloating control buttons for map interactions:
components/map/MapControls.tsxClick-to-add location drawing mode:
components/map/AddLocationMode.tsxClick-to-move existing locations:
components/map/MoveLocationMode.tsxPolygon drawing tool for geographic cuts:
components/map/CutDrawingMode.tsxGeoJSON polygon rendering:
components/map/CutOverlays.tsxCut visibility toggle panel:
components/map/CutOverlayControls.tsxSpecialized map for cut editing:
components/map/CutEditorMap.tsxFloating legend overlay:
components/map/MapLegend.tsxSession header with timer and status:
components/canvass/CanvassHeader.tsxElapsed time display:
components/canvass/SessionTimer.tsxLocation marker with visit status:
components/canvass/CanvassMarker.tsxOptimized marker clustering:
components/canvass/CanvassMarkerGroup.tsxPolyline for walking route:
components/canvass/WalkingRouteLine.tsxGPS position tracking:
components/canvass/GPSTracker.tsxBottom sheet with actions:
components/canvass/CanvassBottomToolbar.tsxVisit outcome form:
components/canvass/VisitRecordingForm.tsxMap legend for canvass status:
components/canvass/CanvassLegend.tsxVideo item display card:
components/media/VideoCard.tsxBatch operation toolbar:
components/media/BulkActions.tsxVideo upload interface:
components/media/UploadVideoModal.tsxResponsive video grid:
components/media/MediaGalleryGrid.tsxEmail template WYSIWYG editor:
components/email-templates/TemplateEditor.tsxTemplate variable selector:
components/email-templates/VariableInserter.tsxPrometheus metrics visualization:
components/observability/MetricsChart.tsxService status display:
components/observability/ServiceHealthCard.tsxLanding page WYSIWYG editor:
components/GrapesJSEditor.tsxLayout 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:
Sidebar Behavior:
Dark theme layout for public-facing pages.
Location: admin/src/components/PublicLayout.tsx
Features:
#0d1b2a background)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:
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:
Mobile Behavior:
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":"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":"Most CRUD pages use Ant Design Table with:
Form pages use Ant Design Form with:
Map pages use React Leaflet with:
Pages use responsive design patterns:
Grid.useBreakpoint()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":"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":"/app/*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:
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
[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":"/app/influence/campaigns/campaigns pagehttp://app.cmlite.org/campaign/{slug}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
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
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
{\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":"title attribute for tooltipsProblem: 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:
Click Save
Browser cache:
Or clear browser cache
Campaign created but not saved:
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:
Share campaign link to supporters
SMTP not configured:
Test connection
BullMQ queue not running:
docker compose logs email-workerSolution: 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:
/campaign/{slug}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":"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/
[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":"/app/canvass/dashboard/app/map/cuts?id={cutId}/app/users?id={userId}Action: Check if any shifts are scheduled, contact volunteers to start shifts
High \"Not Home\" Rate:
Action: Consider rescheduling shifts to evening hours when residents more likely home
Stalled Sessions:
Action: Check for abandoned sessions (volunteers forgot to end session), manually close via backend
Volunteers Off Course:
Action: Contact volunteer to redirect back to assigned territory
Low Visits per Session:
<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?
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?
/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)
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)
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)
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:
setInterval callInterval not returned from useEffect
Interval cleared prematurely:
Cleanup function called too early
API errors silently failing:
Solution:
console.log('Dashboard refresh:', new Date())Check console every 30 seconds for log message
Handle React Strict Mode:
Ensure production build works correctly (no double mounting)
Show API errors:
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:
No sessions with status = ACTIVE
Sessions abandoned:
Sessions marked as ABANDONED instead of ACTIVE
Sessions completed:
Solution:
Navigate to /volunteer/assignments, click \"Start Canvassing\"
Check abandoned sessions:
Manually reopen if volunteer is still active
Adjust status query:
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:
No TrackPoint records exist for sessions
Null GPS coordinates:
Backend filters out volunteers without valid coordinates
Map zoom level:
Solution:
Navigate to /volunteer/canvass/:cutId, verify \"GPS Active\" indicator
Check GPS permissions:
Safari: Preferences \u2192 Websites \u2192 Location \u2192 Allow
Zoom out on map:
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:
Visit records still reference old cutId, inflating count
Duplicate visits counted:
Should count unique locations, not total visits
Backend calculation bug:
Solution:
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 Cap percentage at 100%:
Frontend safety check:
const percentage = Math.min(100, Math.round((visitCount / locationCount) * 100));\n Investigate data integrity:
SELECT * FROM \"CanvassVisit\"\nWHERE \"locationId\" NOT IN (SELECT id FROM \"Location\");\nFile: 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)
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
Fullbleed Layout: - No padding around iframe - Height: calc(100vh - 64px) (full viewport height minus header) - Width: 100% - No border for seamless VS Code integration
Page loads with status check
Check Service Status:
Status badge appears in page header:
View on Desktop:
If on desktop (screen width \u2265 768px):
View on Mobile:
If on mobile (screen width < 768px):
Using Code Server:
npm, git, docker)Git: Source control integration
Common Tasks:
/api/src/modules//admin/src/pages/cd api && npx prisma migrate devnpm run devdocker compose logs -f apiexport 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":"fetchStatus() calledGET /api/docs/status - Check Code Server online statusGET /api/docs/config - Fetch port configurationonline and codeServerPortConstructs URL: //${hostname}:${port}
URL Construction:
window.location.hostname)//localhost:8888 or //app.cmlite.org:8888const 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:
Check Docker container:
docker compose ps code-server\n Check logs:
docker compose logs code-server\n Test direct access:
Open http://localhost:8888 in browser
Restart service:
docker compose restart code-server\n Solutions:
Check CODE_SERVER_PASSWORD env var in .env
Check CSP headers:
Look for Content Security Policy errors
Try \"Open in New Tab\":
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:
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:
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())
Cuts table loads
Select Cut:
Route navigates to /app/map/cuts/:id/export
Review Report Preview:
Address table lists all locations in cut
Print Report:
Ctrl+P (Windows/Linux) or Cmd+P (Mac)Browser print dialog opens
Configure Print Settings:
Background graphics: ON (to print color tags and borders)
Print or Save PDF:
None: Not yet contacted (e.g., 10 addresses)
Calculate Support Percentage:
Example: (80 / 150) \u00d7 100 = 53.3% strong support
Assess Contact Coverage:
Signs: Distribute lawn signs (e.g., 50 sign requests)
Plan Canvassing Strategy:
Bring printed report to field
Review Addresses During Canvass:
See sign requests (bring signs to those addresses)
Update Notes During Canvass:
Mark addresses as \"Visited\" with checkmarks
Data Entry After Canvass:
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":"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
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":"id from URL params (:id in /app/map/cuts/:id/export)GET /api/map/cuts/:id (cut metadata)GET /api/map/cuts/:id/locations (locations with addresses)GET /api/map/cuts/:id/statistics (aggregated statistics)cut, addresses, stats statesSets loading to false
Address Flattening:
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}\nResult: One row per address (not per location)
Statistics Rendering:
stats.total \u2192 Total cardstats.byLevel.LEVEL_1 \u2192 Strong cardstats.byLevel.LEVEL_2 \u2192 Likely cardstats.byLevel.LEVEL_3 \u2192 Unsure cardstats.byLevel.LEVEL_4 \u2192 Oppose cardstats.byLevel.NONE \u2192 None cardstats.withSign \u2192 Signs cardCount emails/phones from addresses array
User Clicks Print:
window.print() calledimport { 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.
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)
<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:
<table> for address grid<th> for column headers<td> for data cellsProper heading hierarchy (<h4> for cut name)
Keyboard Navigation:
\"Print\" button accessible via Tab + Enter
Screen Reader Support:
Tag labels announced (e.g., \"Strong Support\", \"Priority Cut\")
Color Contrast:
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:
/app/map/cuts/5/exportVerify cut with ID 5 exists in table
Check API response:
GET /api/map/cuts/5 requestCheck response:
Navigate from Cuts page:
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:
If 0 locations, cut is empty (no addresses to export)
Assign locations to cut:
Re-export cut
Check locations have addresses:
If no addresses, add address records via CSV import or manual entry
Check API response:
GET /api/map/cuts/:id/locations request[]: No locations in cutaddresses field: Locations exist but no address dataSymptoms: - 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:
/api/map/cuts/:id/statistics) counts ALL addressesThis is expected behavior
Check table filters:
If custom filters added, they may hide addresses from table
Refresh page:
Clears cached data and re-fetches from API
Check API responses match:
GET /api/map/cuts/:id/statistics \u2192 total: 150GET /api/map/cuts/:id/locations \u2192 count addresses in responseSymptoms: - 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:
<style> tag with @media print rules existsIf missing, print CSS not loaded
Enable background graphics:
This ensures table borders and colors print
Try different browser:
If one fails, try another
Check browser console:
Look for CSS errors (e.g., invalid print rules)
Use Ctrl+P instead of button:
Ctrl+P (Windows/Linux) or Cmd+P (Mac)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:
Critical for 8-column table
Check print scaling:
\"Fit to page\" shrinks content, making text too small
Reduce font sizes:
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 Remove less important columns:
const columns = [\n // ... other columns ...\n // Comment out Notes column\n // { title: 'Notes', dataIndex: 'notes', ... },\n];\nThe 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/
[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":"/app/map/cutsNote: 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":"cuts-export-2026-01-15.geojson)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":"cut-{name}-{id}.geojsonExample 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":"/app/map/locations?cutId={cutId}cutId = null on all Location records within polygon (unassigns)Provides save callback after polygon closes
CutOverlays \u2014 Component for rendering cut polygons on map
<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?
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:
id (string): Unique cut identifier (prefixed with \"cut_\")name (string): Cut namedescription (string | null): Optional descriptioncolor (string): Hex color code (e.g., \"#3498db\")geometry (GeoJSON Polygon): Polygon boundary (GeoJSON format)locationCount (number): Number of locations within polygon (calculated field, not stored)createdAt (ISO 8601): Creation timestampupdatedAt (ISO 8601): Last update timestampRequest:
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
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
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
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:
First vertex marker may be small or obscured
Double-click required:
Single-click on first vertex should work but may feel unintuitive
Drawing mode not active:
Solution:
Ensure at least 3 vertices placed before closing
Alternative closing methods:
Right-click and select \"Close Polygon\" from context menu (if implemented)
Visual feedback:
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:
Backend expects FeatureCollection with multiple features
Non-Polygon geometries:
Backend only supports Polygon geometry type
Missing required properties:
Backend requires name to create cut
Invalid JSON syntax:
Solution:
Wrap in FeatureCollection:
{\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\n \"type\": \"Feature\",\n \"geometry\": {...},\n \"properties\": {...}\n }\n ]\n}\n For non-Polygon geometries:
Or manually edit GeoJSON to create Polygon boundaries
For missing properties:
Add \"name\" property to each Feature:
\"properties\": {\n \"name\": \"Untitled Cut\",\n \"description\": \"\",\n \"color\": \"#3498db\"\n}\n For invalid JSON:
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:
Point-in-polygon algorithm receives wrong coordinate order
Locations not geocoded:
Cannot check if point is in polygon without coordinates
Polygon too small:
Zoom in on map to verify polygon size vs. location density
Precision issues:
Solution:
Verify backend point-in-polygon function uses correct order:
isPointInPolygon(\n [location.longitude, location.latitude], // [lng, lat] order\n polygon.coordinates[0]\n);\n For missing coordinates:
Navigate to LocationsPage, bulk geocode locations, then create cut
For small polygons:
Use Location Count filter on LocationsPage to verify locations exist in area
For precision issues:
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:
Foreign key constraint prevents deletion
Active canvass sessions:
Sessions must be closed/deleted before cut can be deleted
Database migration issue:
Solution:
/app/map/shiftsReturn to CutsPage and retry delete
For active sessions:
/app/canvass/dashboardReturn to CutsPage and retry delete
For migration issue (developer fix):
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}\nnpx prisma migrate devThe 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
[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":"/app/app[16, 16] (horizontal, vertical)xs={24} sm={12} lg={6} (responsive card sizing)<>\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":"user \u2014 User object with name field for personalized greetingimport { 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":"useMemo for derived statsimport { 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":"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":"Average Confidence: Percentage score with color-coded threshold (green \u226585%, yellow 60-84%, red <60%)
Geocoding Confidence Breakdown (4 cards)
Manual/None: Count of locations with no confidence score (gray)
Provider Distribution (Dynamic cards)
Dynamic grid layout (adapts to number of providers)
Building Type Distribution (4 cards)
Commercial: Count of commercial properties (purple)
Auto-Refresh
setInterval, cleaned up on unmountNo loading spinner on auto-refresh (seamless updates)
Manual Refresh
Fetches latest statistics from API
Responsive Grid Layout
Consistent gap (16px horizontal, 16px vertical)
Color-Coded Statistics
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":"<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)
<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.
Math.round((stats.geocoded / stats.total) * 100)\n Math.round() is more efficient than .toFixed() for integer percentages.
<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)
<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).
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":"Persists layout preferences in localStorage
File Tree Browser
Search/filter with auto-expand matching nodes
Monaco Code Editor
Detects file type (markdown, yaml, json, css, html, javascript)
MkDocs Snippet System (60+ snippets)
Snippet types: wrap (surround selection), block (insert template), insert (paste content)
Formatting Toolbar
Keyboard shortcuts shown in menus (Ctrl+B, Ctrl+I)
Live Preview
/mkdocs-proxy/)Click URL buttons to open in new tab
File Operations
Root-level creation: Toolbar buttons (+ File, + Folder icons)
File Tree Actions
Show Panel: Click thin bar or unfold icon to restore tree
Save Operations
Modified indicator in editor status bar
Layout Modes
MkDocs Site Building (SUPER_ADMIN)
Mobile Detection
index.md)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":"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":"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":"https://docs.cmlite.org/{path} in new tabhttp://localhost:4003/{path} in new tab<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: '' },\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.
{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":"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":"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/
[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":"/app/influence/email-queueWhen to Pause: - Troubleshooting SMTP connection issues - Performing backend maintenance - Preventing emails from sending during off-hours - Testing email configuration changes
Steps:
/api/email-queue/pauseEffect: - 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":"/api/email-queue/resumeWhen to Clean: - Completed job count exceeds 10,000 (memory usage) - Queue dashboard feels sluggish - Regular maintenance (weekly/monthly)
Steps:
/api/email-queue/cleanImportant: 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":"/api/email-queue/statsProblem: \"Failed\" count increases (e.g., from 5 to 12)
Diagnosis Steps:
docker compose logs -f api | grep \"Email job failed\"Email job failed for campaign abc123: SMTP connection timeout\nResolution:
<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)
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?
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?
fetchStats in dependency array, so must be stable/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:
await queue.getWaitingCount() \u2014 jobs in \"wait\" stateawait queue.getActiveCount() \u2014 jobs in \"active\" stateawait queue.getCompletedCount() \u2014 jobs in \"completed\" stateawait queue.getFailedCount() \u2014 jobs in \"failed\" stateRequest:
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
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?
setPageHeader, which triggers useEffect if reference changesQueue 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?
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)
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:
User needs to log out and log back in
Interval cleared prematurely:
useEffect cleanup called too early
Backend API down:
Solution:
Check JWT_ACCESS_SECRET and JWT_REFRESH_SECRET env vars
For interval issues:
Ensure production build works correctly (no double mounting)
For backend issues:
docker compose ps apidocker compose logs -f api | grep email-queuedocker compose restart apiProblem: 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:
Redis connection error during queue initialization
Redis connection lost:
Network connectivity issue between API and Redis
Multiple workers:
Solution:
docker compose logs api | grep \"Email queue service\"If missing, check Redis connection
For Redis issues:
docker compose ps redisdocker compose exec redis redis-cli PINGIf down, restart: docker compose restart redis
For multiple workers:
docker compose ps apidocker compose up -d --scale api=1Problem: \"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:
Rate limiting (too many emails sent)
Invalid recipient addresses:
Blocked by recipient server
Network connectivity:
Solution:
/app/settings, click \"Test Connection\"Wait 5 minutes if rate limited, then resume queue
For invalid addresses:
/app/influence/campaigns/app/influence/representativesDelete invalid addresses or update to correct ones
For network issues:
sudo iptables -L | grep 587nslookup smtp.protonmail.chtelnet smtp.protonmail.ch 587Problem: 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:
BullMQ clean() called with wrong parameters
User misunderstanding:
Solution:
await queue.clean(0, 'completed'); // Only completed, not 'failed' or 'waiting'\nTest: Add jobs to queue, click clean, verify waiting/failed jobs remain
For unclear UI:
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:
Job retry logic creating duplicates
Test jobs:
Test mode emails counting toward total
Redis not cleaned:
Solution:
Add deduplication: Check if job already exists before creating
For test jobs:
Use separate test queue (not production queue)
For stale jobs:
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":"Line numbers, word wrap, no minimap for clean editing
Subject Line Editor
{{CAMPAIGN_NAME}})Saved together with HTML/text content
Variables Reference Panel
{{FIRST_NAME}})Persists sample values during editing session
Real-Time Previews
Variable interpolation uses simple string replacement
Save Operations
Updates template timestamp
Test Email Functionality
Success message on send
Template Metadata Display
Back button to return to templates list
Mobile Detection
Back button to return to templates list
Dark Theme Editor
/app/email-templates/:id/edit{{VARIABLES}} as needed{{VARIABLE_NAME}} from table/app/email-templates<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
<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
<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).
<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.
<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
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.
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.
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
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)
// 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.
<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":"<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)
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":"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":"Four action buttons per row (Edit, Test, Versions, Delete)
Search & Filtering
Filters trigger automatic refetch with page reset to 1
Template Actions
/app/email-templates/:id/edit)Delete: Popconfirm with warning (only for non-system templates)
Pagination Controls
Current page and page size preserved during search/filter operations
System Template Protection
isSystem: true) cannot be deletedBlue SYSTEM tag displayed in name column
Test Email Modal
Success message on send
Version History
<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
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.
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.
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.
<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":"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 <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.
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":"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
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:
Check Docker container:
docker compose ps gitea\n Check logs:
docker compose logs gitea\n Restart service:
docker compose restart gitea\n Symptoms: Iframe shows Gitea login screen
Solutions:
.env:GITEA_ADMIN_USERGITEA_ADMIN_PASSWORD
Login manually with admin credentials
Create user account if needed
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/
[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":"/app/pages/app/pages/:id/edit (page editor)Editor Mode Selection:
Best for non-technical users
Code Editor:
Slug Generation:
Title \"About Our Campaign\" \u2192 Slug \"about-our-campaign\"
/app/pages/:id/edit/app/pagespublished: true/p/:slug (public access)published: false/p/:slug (404 error)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":"/p/:slugNote: 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":"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/
[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":"/app/listmonkSync 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:
/api/health endpointSuccess 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:
SELECT DISTINCT email, name FROM Response WHERE verified = trueSync 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:
SELECT * FROM Location WHERE email IS NOT NULL AND deletedAt IS NULLSubscriber 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:
SELECT * FROM User WHERE deletedAt IS NULLSubscriber 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:
Performance:
When to Reinitialize: - Lists accidentally deleted in Listmonk - Fresh Listmonk installation - Corrupted list data
Steps:
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:
/api/listmonk/proxy-url{ port: 9001, token: \"auto-auth-token-xyz\" }//localhost:9001/auth?token=auto-auth-token-xyzUse 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:
//localhost:9001LISTMONK_WEB_ADMIN_USER env varLISTMONK_WEB_ADMIN_PASSWORD env varNote: 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":"<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
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?
iframeInitialized ref prevents redundant loadsWhen 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:
/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)
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
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:
iframeInitialized.current is false, so iframe loadsiframeInitialized.current is true, so function returns early (no redundant loads)useRef value persists across re-renders (unlike state)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
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
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:
Edit .env file:
nano .env\n Add or update line:
LISTMONK_SYNC_ENABLED=true\n Restart API container:
docker compose restart api\n Refresh page to see enabled buttons
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:
Failed to start due to configuration error
Wrong credentials:
LISTMONK_ADMIN_USER or LISTMONK_ADMIN_PASSWORD incorrectAPI user not created in Listmonk database
Network issue:
Solution:
Start Listmonk:
docker compose up -d listmonk\n Verify credentials:
grep LISTMONK_ .env\n Check that LISTMONK_ADMIN_USER and LISTMONK_ADMIN_PASSWORD match Listmonk configuration.
curl -u admin:password http://localhost:9001/api/health\nExpected: {\"version\":\"2.3.0\"}
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:
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:
Redis down (tokens stored in Redis)
X-Frame-Options blocking:
X-Frame-Options: SAMEORIGINBrowser blocks iframe from different origin
CORS issue:
Solution:
docker compose ps redis\ndocker compose exec redis redis-cli PING\nExpected: \"PONG\"
Manual login required (no auto-auth token)
Configure Listmonk to allow iframes (developer fix):
X-Frame-Options headerThe 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
[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":"/app/map/locations./data volume mount) for:Addresses/ directory with Address_{provinceCode}part.csv filesLocations/ directory with Location_{provinceCode}.csv filesNAR 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":"/api/map/locations/:id with new lat/lngaddress, latitude)locations-YYYY-MM-DD.csvconst 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
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
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:
GOOGLE_GEOCODING_API_KEY=your-key-hereCheck quota limits
Poor address quality:
Solution: Clean address data before import
Provider fallback chain:
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:
Data directory doesn't exist:
mkdir -p ./data\n NAR files not extracted:
./data/ directoryEnsure Addresses/ and Locations/ subdirectories exist
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 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:
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:
Solution: Reduce job concurrency in backend
Redis connection lost:
docker compose ps redisSolution: Restart redis: docker compose restart redis
Job worker crashed:
docker compose restart apiSolution:
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)
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:
NAR CSV: Address file requires CIVIC_NO + OFFICIAL_STREET_NAME (2025) or STR_NBR + STR_NME (legacy)
Invalid coordinates:
Non-numeric values in lat/lng columns
Encoding issues:
Solution:
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)
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
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":"Page loads with status check
Check Service Status:
Status badge appears in page header:
View on Desktop:
If on desktop (screen width \u2265 768px):
View on Mobile:
If on mobile (screen width < 768px):
Using MailHog Service:
Raw View: View raw email source (headers + body)
Troubleshoot Offline Service:
docker compose ps mailhogdocker compose restart mailhognginx/conf.d/services.confexport 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":"// 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":"fetchStatus() called in useEffectGET /api/services/status - Check MailHog online statusGET /api/services/config - Fetch service configurationonline to true or falseconfig with subdomain/domain/portSets loading to false
URL Construction:
buildServiceUrl() constructs full service URL from confighttp://mailhog.cmlite.org (production with subdomain)Or: http://localhost:8025 (development with port)
User Clicks Refresh:
fetchStatus() called againUpdates online and config states
Service Online:
online is trueIframe renders with MailHog interface
Service Offline:
online is falseconst 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":"<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":"#52c41a on white background (contrast ratio 4.5:1)#ff4d4f on white background (contrast ratio 4.5:1)#1890ff (contrast ratio 4.5:1)Solutions:
Verify Docker container:
docker compose ps mailhog\n# Should show \"Up\" status\n Check MailHog logs:
docker compose logs mailhog\n# Look for errors\n Test direct access:
http://localhost:8025 in browserIf accessible directly, nginx routing issue
Check nginx config:
nginx/conf.d/services.confRestart nginx: docker compose restart nginx
Verify API endpoint:
/api/services/status requestmailhog.online: true in responseSolutions:
Check nginx X-Frame-Options headers
Verify service URL:
console.log(serviceUrl)Should be valid URL (not null/undefined)
Test URL in new tab:
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/
[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":"/app/map/settingsSearch 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":"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":"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":"/api/qr endpoint)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":"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
Option 2: Print from Header Button
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":"<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 & 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?
/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:
body * { visibility: hidden } hides navigation, buttons, etc..walk-sheet-print * { visibility: visible } shows only previewprint-color-adjust: exact ensures QR codes print with high contrast@page { size: letter } sets printer page sizeconst 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?
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
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:
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?
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:
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)
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:
Network connectivity issue
Invalid query:
Special characters causing parsing errors
Rate limiting:
Solution:
docker compose logs api | grep geocodingcurl \"https://nominatim.openstreetmap.org/search?q=Ottawa&format=json\"If down, wait 5 minutes and retry
For invalid queries:
Try simpler query (e.g., \"Ottawa\" instead of \"Ottawa, ON, Canada\")
For rate limiting:
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:
Special characters not URL-encoded
QR API endpoint down:
QR code generation service crashed
Browser caching:
Solution:
https:// not www.Check for special characters: URL-encode if necessary
For API issues:
docker compose logs api | grep qrdocker compose restart apiTest endpoint: curl \"http://localhost:4000/api/qr?text=test&size=200\"
For caching:
&v=${Date.now()} to QR code URLProblem: 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:
CSS @media print not working
CSS specificity issue:
!important flags not effective
Browser print settings:
Solution:
body * { visibility: hidden !important; }@page { margin: 0; } to remove default marginsTest in multiple browsers (Chrome, Firefox, Safari)
For browser settings:
Select \"None\" for margins
Alternative (if CSS fails):
window.open('/walk-sheet-preview')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:
Error message shown but not noticed
Database not updated:
Transaction rolled back due to error
Public map caching:
Solution:
docker compose logs api | grep \"settings saved\"Check for error messages: Look for red toast notification
For database issues:
docker compose exec v2-postgres psql -U postgres -d v2 -c \"SELECT * FROM \\\"MapSettings\\\"\"If mismatch, manually update: UPDATE \"MapSettings\" SET latitude = '45.4215', longitude = '-75.6972'
For caching:
/api/map/settings returns new coordinatesFile: 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)
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":"Page loads with status check
Check Service Status:
Loading spinner shown during status check
View on Desktop:
If on desktop (screen width \u2265 768px):
View on Mobile:
If on mobile (screen width < 768px):
Using Mini QR Service:
Download QR code image
Troubleshoot Offline Service:
docker compose ps mini-qrdocker compose restart mini-qrnginx/conf.d/services.confconst 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":"<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
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":"checkServiceStatus() called in useEffectloading to trueGET /api/services/mini-qr/statusonline to true or false based on responseSets loading to false
Service Online:
online is trueIframe renders with Mini QR service embedded
Service Offline:
online is falseNo iframe rendered (blank space below alert)
Mobile Device:
isMobile is trueimport { 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)
// 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
Navigates through iframe content (if iframe supports tab navigation)
Enter Key:
Interacts with iframe elements
Escape Key:
<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)
title attributerole=\"alert\" for announcementsSymptoms: - 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:
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 Check nginx routing:
nginx/conf.d/services.conflocation /qr/ {\n proxy_pass http://mini-qr:8089/;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n}\nRestart nginx: docker compose restart nginx
Test direct access:
http://localhost:8089 (direct container port)If not accessible directly, service issue
Check service health endpoint:
curl http://localhost:8089/health\n# Should return 200 OK\n Verify API endpoint:
GET /api/services/mini-qr/status requestCheck response:
{\"online\": true} - Service should work{\"online\": false} - Service health check failedRestart services:
docker compose restart mini-qr nginx api\n 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:
These indicate CORS/CSP blocking
Verify X-Frame-Options header:
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}\nOr set to SAMEORIGIN to allow same-domain embedding:
add_header X-Frame-Options \"SAMEORIGIN\";\n 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 Test iframe in isolation:
<!DOCTYPE html>\n<html>\n<body>\n <iframe src=\"http://qr.cmlite.org\" width=\"800\" height=\"600\"></iframe>\n</body>\n</html>\nIf iframe doesn't work here either, service configuration issue
Verify service URL:
src attribute in codehttp://qr.cmlite.org (nginx proxied)http://localhost:8089 (for testing only)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:
Ctrl+0 (Windows/Linux) or Cmd+0 (Mac) to reset zoom to 100%Refresh page
Maximize browser window:
F11 for fullscreenRefresh page
Close DevTools or dock to bottom:
Refresh page
Check breakpoint detection:
window.innerWidthResize window wider and refresh
Clear browser cache:
Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (Mac)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:
Click \"Retry\" again
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 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 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 Hard refresh page:
Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (Mac)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:
width: 100% appliedVerify parent container has sufficient width
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 Add back sandbox with minimal restrictions
Use viewport meta tag in service:
If Mini QR is custom service, add to its HTML:
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n 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 Open in new tab:
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
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":"Page loads with 4 tabs at top
Select Settings Tab:
Shows form with ~15 fields
Edit Site Information:
Configure copyright notice
Configure Repository Links:
Configure edit URI pattern (e.g., \"edit/main/docs/\")
Customize Theme:
Add theme features as tags:
Configure Plugins:
Click X on tags to remove
Add Markdown Extensions:
Add extensions as tags:
Save Settings:
Tree view loads with current navigation
Understand Tree Structure:
Expandable: Click arrow to expand/collapse sections
Reorder Items (Drag and Drop):
Changes saved automatically
Add New Section:
New section appears in tree
Add New Page:
New page appears in tree
Edit Navigation Item:
Click \"Save\"
Remove Navigation Item:
Item removed from navigation (file remains on disk)
Handle Orphaned Files:
File moves from orphaned list to navigation tree
Add Campaign Links:
Campaign link inserted into navigation with auto-generated path
Save Navigation:
Monaco editor loads with current mkdocs.yml content
Check Mobile Warning:
If on mobile device, warning Alert shows:
Edit Raw YAML:
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 Use Keyboard Shortcuts:
Ctrl+Y: Redo
Handle Python Tags:
nav:\n - Home: !relative $config_dir/index.md\nTags preserved during parse/stringify cycle
Save YAML:
YAML validated and saved to database
Handle YAML Errors:
Build status card loads
Check Last Build:
Example: \"Built 2 hours ago\"
Trigger New Build:
Build starts in background
Monitor Build Progress:
Wait 10-30 seconds for build to complete
View Build Result:
Error: Red X, error message displayed
Access Built Site:
http://localhost:4001 (production)http://localhost:4003 (dev server)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":"<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":"loadConfig() called in useEffectGET /api/docs/configconfig, navStructure, orphanedFiles, yamlContentSets form field values
User Edits Settings Tab:
form state (Ant Design managed)handleSaveSettings()form.getFieldsValue()PUT /api/docs/config with updated configRe-fetches config on success
User Edits Navigation Tab:
navStructure statenavStructurehandleSaveNavigation()PUT /api/docs/config with updated nav structureRe-fetches config on success
User Edits YAML Tab:
yamlContent state on changehandleSaveYAML()PUT /api/docs/config with parsed YAML objectRe-fetches config on success
User Triggers Build:
handleBuild()building to truePOST /api/docs/buildbuilding to false on completionlastBuild timestampimport { 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 © 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 © 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":"Cycles through all interactive elements (tabs, form fields, buttons, tree nodes)
Arrow Keys:
Navigate tree structure (up/down/left/right)
Enter Key:
Expand/collapse tree nodes
Escape Key:
Cancel drag-and-drop operations
Monaco Editor Shortcuts:
Ctrl+S / Cmd+S - Save YAMLCtrl+F - FindCtrl+H - Find and replaceCtrl+Z - UndoCtrl+Y - Redo<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)
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":"<Form.Item label>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:
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 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 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 Use Monaco Find to locate errors:
Ctrl+F to open find dialog: to verify all keys have valuesSearch for \" to verify all quotes are closed
Copy YAML to external validator:
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:
Look for errors related to \"onDrop\" or \"Tree\"
Refresh page:
Check if drag-and-drop works after refresh
Try alternative reordering:
Delete item and re-add in desired position
Verify tree data structure:
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 Report browser compatibility:
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:
Verify file is truly not present in any section
Check file location:
mkdocs/docs/ directoryMove files to mkdocs/docs/ if needed
Verify file has .md extension:
.md files detectedFiles with .txt, .html, etc. won't appear
Refresh orphaned files:
Check if files appear after save
Manually add files:
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:
Check for validation errors (red borders on inputs)
Verify file references:
nav: section for file pathsEnsure 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 Check MkDocs container status:
docker compose psmkdocs container is \"Up\"If not, start container: docker compose up -d mkdocs
View build logs:
docker compose logs mkdocsFix issues indicated in logs (e.g., missing plugin, theme error)
Verify theme and plugins installed:
api/mkdocs/requirements.txt for installed packagesdocker compose up -d --build mkdocsSymptoms: - 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:
Look for API errors (400, 500 status codes)
Verify form validation:
Fix validation errors before saving:
Check API response:
PUT /api/docs/config requestCheck Response tab for error details
Verify database connection:
docker compose logs apiIf errors, restart API: docker compose restart api
Check YAML Editor for conflicts:
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:
Wait for editor to fully initialize before interacting
Check network requests:
If failed, refresh page to retry
Clear browser cache:
Reload page to fetch fresh assets
Update browser:
Restart browser after update
Use alternative tabs:
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:
Return to MkDocs Settings and try again
Check browser console:
Look for API errors (401, 403, 500)
Verify permissions:
SUPER_ADMIN roleIf not SUPER_ADMIN, request role upgrade from administrator
Check API endpoint:
GET /api/influence/campaigns requestIf empty response, no campaigns available
Manually add campaign pages:
campaigns/climate-action-now.mdmkdocs/docs/campaigns/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":"Page loads with status check
Wait for Load:
n8n editor loads in iframe
Create New Workflow:
Empty canvas appears
Add Trigger Node:
Select trigger type:
Add Action Nodes:
Configure node:
Test Workflow:
Debug errors if any
Activate Workflow:
Workflow now runs automatically based on trigger
Monitor Executions:
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":"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
Symptoms: - Iframe shows n8n login screen - Cannot access workflows without credentials
Solutions:
N8N_BASIC_AUTH_USER env varPassword: N8N_BASIC_AUTH_PASSWORD env var
Login manually:
n8n saves session in browser cookies
Disable authentication (dev only):
N8N_BASIC_AUTH_ACTIVE=false in .envdocker compose restart n8nSymptoms: - Workflow shows red error icon - Execution stopped at specific node - Error message displayed
Solutions:
Verify credentials valid
Check credentials:
Re-enter if expired
View error details:
Common errors:
Test individual nodes:
Symptoms: - Webhook workflow not executing - External service sending webhooks but n8n not responding
Solutions:
http://n8n.cmlite.org/webhook/responseVerify URL accessible from external service
Check workflow active:
Inactive workflows don't respond to webhooks
Test webhook manually:
curl -X POST http://n8n.cmlite.org/webhook/response \\\n -H \"Content-Type: application/json\" \\\n -d '{\"test\": \"data\"}'\n Check execution history in n8n
Check nginx routing:
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":"Page loads with status check
Wait for Load:
NocoDB interface loads in iframe
Select Table:
Click table name to view contents
View Table Data:
Click row to expand details
Filter Data:
View filtered results
Export Data:
Download file for offline analysis
Common Use Cases:
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":"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
Symptoms: - Iframe shows NocoDB login screen - Cannot access tables without credentials
Solutions:
NC_ADMIN_EMAIL env varPassword: NC_ADMIN_PASSWORD env var
Login manually:
NocoDB saves session in browser cookies
Reset password:
docker compose exec nocodb sh\nnc-cli reset-password --email admin@example.com\n Symptoms: - NocoDB loads but no tables in left sidebar - \"No projects found\" message
Solutions:
Check NC_DB env var: postgresql://user:password@host:port/database
Create NocoDB project:
Enter database credentials (from V2_POSTGRES_* env vars)
Restart NocoDB:
docker compose restart nocodb\n 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":"Tab state preserved during session
Service Status Monitoring
Responsive grid layout (4 columns on desktop, 2 on tablet, 1 on mobile)
Auto-Start Banner
docker compose --profile monitoring up -dOnly shows when servicesOnline === 0
Key Metrics Grid
Powered by MetricsGrid component
Active Alerts Table
Powered by AlertsTable component
Grafana Dashboard Iframe
calc(100vh - 200px))allow-scripts, allow-same-origin, allow-forms)Shows warning if Grafana offline
Alertmanager Iframe
calc(100vh - 200px))Shows warning if Alertmanager offline
Refresh Button
Loading state during refresh
Open Grafana Button
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
<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
<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)
{!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)
<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":"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
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
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":"/app/pages (LandingPagesPage)View table of all landing pages
Select Page to Edit:
URL changes to /app/pages/:id/edit
Wait for Editor Load:
Upload Images: Use Asset Manager to add media
Canvas Controls:
Toggle borders/padding visualization
Save Changes:
Syntax highlighting for HTML tags
Save Changes:
Success message: \"Page saved\"
Limitations:
API updates published field immediately
When Published:
Page accessible at /p/:slug URL
Preview Published Page:
/p/:slugexport 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
// 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
// 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":"loading set to trueuseEffect triggers fetch based on modepage, blocks, codeContentSets loading to false
User Edits Content:
Code mode: Monaco onChange updates codeContent
User Saves:
editorRef.current?.triggerSave() \u2192 handleSaveVisual callbackhandleSaveCode directlysaving to truepage with responseSets saving to false
User Toggles Published:
published fieldpage with responseconst [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
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":"Enters editor focus (Monaco or GrapesJS canvas)
Ctrl+S / Cmd+S:
Prevents browser default \"Save Page As\" dialog
GrapesJS Keyboard Shortcuts:
Ctrl+C/V: Copy/paste components
Monaco Editor Shortcuts:
<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:
curl -H \"Authorization: Bearer <token>\" \\\n http://localhost:4000/api/pages/<page-id>\nVerify blocks, htmlOutput, editorMode fields exist
Check browser console:
Common errors:
blocks field missing/corruptClear browser cache:
Ctrl+Shift+R (hard refresh)
Check network tab:
Symptoms: - Click Save button, no success message - Loading spinner appears but never completes - Console shows 400/500 errors
Solutions:
/api/pages/:id requesthtmlOutput, blocks, cssOutput)Check response status code
Visual Mode - Invalid blocks data:
Try creating new page instead of editing corrupt one
Code Mode - Invalid HTML:
Check for script injection attempts (blocked by CSP)
Network timeout:
admin/src/lib/api.tsSymptoms: - Press Ctrl+S, nothing happens - Browser \"Save Page As\" dialog appears instead
Solutions:
Keyboard handler requires window focus
Check browser extensions:
Disable extensions one by one
Mac users: Use Cmd+S instead of Ctrl+S
Handler supports both e.ctrlKey and e.metaKey
Manual save as fallback:
Symptoms: - Toggle \"Published\" switch to ON - Navigate to /p/:slug, get 404 error
Solutions:
Check for URL conflicts with existing routes
Check page published status:
curl -H \"Authorization: Bearer <token>\" \\\n http://localhost:4000/api/pages/<page-id>\n Verify \"published\": true in response
Check public route registration:
admin/src/App.tsxVerify public route exists:
<Route path=\"/p/:slug\" element={<LandingPage />} />\n Check nginx routing:
Verify nginx reverse proxy configuration
Hard refresh public page:
Symptoms: - Visual editor loads but block panel is empty - Only default blocks visible (Text, Image, etc.)
Solutions:
curl -H \"Authorization: Bearer <token>\" \\\n http://localhost:4000/api/page-blocks\nShould return array of blocks with label, content, media
Check blocks passed to GrapesJS:
console.log('Custom blocks:', blocks);\nVerify array not empty
Check GrapesJS block registration:
admin/src/components/GrapesJSEditor.tsxVerify blocks registered in editor.BlockManager.add()
Clear GrapesJS localStorage:
gjsProject-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":"Auto-refresh on status change
Setup Wizard (First-Time Configuration)
changemaker-{domain})Post-setup credential display with show/hide toggle
Credential Management
Newt container restart button after credential update
Resource Management
Restart Newt button in table header
Resource Editing
Update button saves changes to Pangolin API
Exit Node Support
Graceful fallback if no exit nodes (self-hosted setups)
Newt Container Management
Success/error messages for restart operations
Subnet Auto-Suggestion
100.90.128.2/24)100.90.128.3/24 if no sites existAllows manual override if suggested subnet conflicts
Security Features
docker compose restart apichangemaker-cmlite.org (or custom)100.90.128.3/24 (auto-suggested, or override)api.cmlite.org){created} created, {skipped} skippedScenario 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":"http or https<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 '<': '<',\n '>': '>',\n \"'\": ''',\n '\"': '"',\n '&': '&',\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
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":"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/
[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":"/app/influence/representativesconst 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
\nNo Global State:
\nThis 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
\nWhy 300ms Debounce?
\nconst 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\nWhy useCallback?
\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:
\nconst 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\nQuery 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)
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\nResponse Fields:
\nCore 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
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
Request:
\nconst { data } = await api.get<CacheStats>('/representatives/stats');\n\nResponse (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\nResponse 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)
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:
\nconst postalCode = 'K1A 0A9';\nconst { data } = await api.post<{\n message: string;\n count: number;\n representatives: Representative[];\n}>(`/representatives/lookup/${postalCode}`);\n\nURL Parameter:\n- postalCode (string): Canadian postal code (format: \"A1A 1A1\" or \"A1A1A1\")
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\nResponse (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\nResponse (200 OK) - No Representatives Found:
\n{\n \"message\": \"No representatives found for K1A 0A9\",\n \"count\": 0,\n \"representatives\": []\n}\n\nError 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\nError 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\nBackend 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:
\nconst repId = 'rep_abc123';\nawait api.delete(`/representatives/${repId}`);\n\nURL Parameter:\n- id (string): Representative ID to delete
Response (200 OK):
\n{\n \"message\": \"Representative deleted from cache\"\n}\n\nError 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:
\nconst { data } = await api.delete<{ message: string; count: number }>('/representatives/cache');\n\nResponse (200 OK):
\n{\n \"message\": \"Cache cleared successfully\",\n \"count\": 245\n}\n\nResponse Fields:\n- message (string): Confirmation message\n- count (number): Number of representatives deleted
Backend Implementation:
\nconst 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\nKey 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\nConfirmation 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)
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\nEnhanced 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\nColor 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\nTwo 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
The page uses server-side pagination to handle large cache datasets efficiently:
\nconst { data } = await api.get('/representatives', {\n params: {\n page: pagination.current,\n limit: pagination.pageSize,\n search,\n postalCodeFilter,\n levelFilter,\n },\n});\n\nBenefits:\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:
\nsearchTimerRef.current = setTimeout(() => {\n setSearch(value);\n}, 300);\n\nPerformance 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:
\nconst loadRepresentatives = useCallback(async () => {\n // ... fetch logic\n}, [pagination.current, pagination.pageSize, search, postalCodeFilter, levelFilter]);\n\nWhy 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:
\nconst 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\nBenefits:\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:
\nStatistics 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\nFilter 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\nResponsive 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 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\nMobile Table (xs, sm):\n- Name (visible)\n- Level (visible with color tags)\n- Actions (visible)
\nDesktop 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\nBehavior:\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:
\nTable 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)
\nForm 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
\nModal/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:
\nStatistics Cards:\n
<Statistic\n title=\"Cached Representatives\" // Read by screen readers\n value={stats.totalRepresentatives}\n prefix={<TeamOutlined />}\n/>\n\nAction 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\nTable 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:
\nGovernment 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
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)
All interactive elements have visible focus states:
\nButtons:\n
.ant-btn:focus {\n outline: 2px solid #1890ff;\n outline-offset: 2px;\n}\n\nInput 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.
\nDiagnosis:
\nCheck browser console for errors:
\n// Console error\nGET https://api.cmlite.org/representatives 401 Unauthorized\n\nPossible Causes:
\nCheck if user has required role (SUPER_ADMIN or INFLUENCE_ADMIN)
\nBackend API down:
\ndocker compose ps apiCheck API logs: docker compose logs api
Database connection issue:
\ndocker compose ps v2-postgresdocker compose exec api npx prisma db pushSolution:
\ndocker compose up -d api v2-postgresdocker compose logs -f api | grep representativesProblem: Click \"Lookup New Postal Code\", enter postal code, get error: \"Failed to lookup representatives\".
\nDiagnosis:
\nCheck 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\nPossible Causes:
\nNetwork firewall blocking external API requests
\nInvalid postal code format:
\nNon-Canadian postal code entered
\nRate limit exceeded:
\nSolution:
\nUse cached representatives if available (search for existing postal code)
\nFor invalid postal code:
\nTry a known-valid postal code: \"K1A 0A9\" (Ottawa, Parliament Hill)
\nFor rate limits:
\nProblem: Click \"Delete\" button, confirmation modal appears, click \"Delete\" again, but representative remains in table.
\nDiagnosis:
\nCheck 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\nPossible Causes:
\nJWT token has expired and refresh failed
\nRepresentative already deleted:
\nTable hasn't refreshed to reflect deletion
\nDatabase constraint violation:
\nSolution:
\nCheck user role in profile dropdown (top-right corner)
\nFor concurrent deletion:
\nIf representative is gone, deletion succeeded (UI just didn't update)
\nFor constraint violations:
\nProblem: Type search query in \"Search Representatives\" field, but table doesn't filter.
\nDiagnosis:
\nCheck if debounce timer is working:
\n// Wait 300ms after typing\n// If table still doesn't update, check console for errors\n\nPossible Causes:
\nMust wait 300ms after last keystroke
\nCase sensitivity:
\nAccent characters (\u00e9, \u00e0, \u00f1) may not match correctly
\nSearch scope confusion:
\nSolution:
\nWatch for table loading spinner to confirm search triggered
\nFor special characters:
\nUse partial matches (e.g., \"Smith\" instead of \"O'Smith\")
\nFor search scope:
\nProblem: Add new representatives via lookup, but statistics cards still show old counts.
\nDiagnosis:
\nCheck if loadStats() is called after lookup:
// 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\nPossible Causes:
\nloadStats() not called after successful lookupPromise.all([loadRepresentatives(), loadStats()]) failed silently
\nBackend calculation error:
\nDatabase aggregation query not reflecting new records
\nCache invalidation:
\nSolution:
\nStatistics should update to reflect current cache state
\nCheck backend logs:
\ndocker compose logs api | grep statsVerify database connection during stats calculation
\nDeveloper fix (if bug):
\nloadStats() is called after lookup/delete:\n await Promise.all([loadRepresentatives(), loadStats()]);\nThe 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
[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":"/app/influence/responsesrepresentativeEmail (not null)isVerified set to trueVerification 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
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":"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
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
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).
<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
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":"title attributeProblem: 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:
Solution: Not fixable (representative doesn't have email)
Email not fetched from API:
representativeEmail fieldSolution: 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:
showResponseWall is falseSolution: Edit campaign, enable Show Response Wall, save
Response not verified:
Solution: Click Verify button, user clicks email link
Browser cache:
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:
Solution: Switch to Production provider in Settings
SMTP credentials invalid:
Solution: Update SMTP credentials, re-test
Spam folder:
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:
/app/influence/campaignsReturn to responses page
Campaigns API failing:
docker compose logs api | grep \"campaigns\"Solution: Create at least one campaign before filtering responses.
"},{"location":"v2/frontend/pages/admin/responses-page/#related-documentation","title":"Related Documentation","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
[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":"/app/settingsconst 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":"settings \u2014 Current settings objectloading \u2014 Loading statefetchAdminSettings() \u2014 Load settings from APIupdateSettings(partial) \u2014 Update and persist settingsimport { 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:
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?
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:
Port 25 often blocked by ISPs
App-specific password required:
ProtonMail requires ProtonMail Bridge for SMTP
Wrong provider selected:
Solution:
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:
Check active provider:
settings.smtpActiveProvider === 'mailhog' // MailHog (dev)\nsettings.smtpActiveProvider === 'production' // Real SMTP\n Check test mode:
settings.emailTestMode === true // All emails redirect to testEmailRecipient\n Check spam folder
Check MailHog web UI (http://localhost:8025) if MailHog is active
Solution:
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
[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":"/shifts page/app/map/shifts/shifts page/volunteer/assignments pageTemp 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":"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
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)
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)
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).
{\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.
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":"title attributeProblem: 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:
docker compose logs api | grep \"shift status\"Verify signup endpoint includes auto-status update
Race condition:
Solution: Use Prisma transaction for atomic updates
Status manually set:
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:
Save settings
SMTP credentials invalid:
Re-test before emailing
No confirmed volunteers:
Solution:
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:
Verify signup status in database
Wrong shift:
Verify shift ID in URL when opening drawer
Duplicate email:
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:
/app/map/cutsReturn to shifts page
Cuts API failing:
docker compose logs api | grep \"cuts\"Verify database connection
Cuts fetch not called:
fetchCuts() called in useEffectSolution:
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:
Edit shift, update date to future
Status CANCELLED:
Change status to OPEN
Browser cache:
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":"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
[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":"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":"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?
/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:
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:
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?
React.memo effectivelyServer-side pagination reduces memory usage:
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:
debouncedSearch value in React DevToolsfetchUsers is called after 300ms delaysearch paramSolution:
useEffect(() => {\n fetchUsers({ page: 1 });\n}, [debouncedSearch, roleFilter, statusFilter]);\n Ensure debouncedSearch is in dependency array, not search.
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:
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":"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
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
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
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:
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
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())
Scroll to \"Walk Sheet Configuration\" section
Set Walk Sheet Title:
This appears as main heading on printed sheet
Set Walk Sheet Subtitle (Optional):
Appears below title in smaller font
Configure QR Codes (Up to 3):
https://cmlite.org/responses/1)QR codes appear centered above contact table
Set Footer Text (Optional):
Appears at bottom of printed sheet
Save Settings:
Page loads with preview of walk sheet
Review Preview:
Confirm footer text appears
Print Walk Sheet:
Ctrl+P (Windows/Linux) or Cmd+P (Mac)Browser print dialog opens
Configure Print Settings:
Background graphics: ON (to print table borders clearly)
Print or Save PDF:
Fill in volunteer name, date, area/cut at top
At Each Door:
Write notes (e.g., \"Call back after 6pm\", \"Not home\")
Completing Walk Sheet:
Organizer enters data into system via Admin GUI
QR Code Usage:
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> </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> </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":"<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)
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":"loadSettings() called in useEffectGET /api/map/settingssettings stateSets loading to false
Settings Loaded:
walkSheetTitle, walkSheetSubtitle, walkSheetFooterqrCode1Url/Label, qrCode2Url/Label, qrCode3Url/LabelRenders walk sheet with settings
User Clicks Print:
window.print() calledimport { 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 }}> </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 }}> </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 }}> </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 }}> </td>\n <td> </td>\n <td> </td>\n <td> </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> </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.
// 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:
<table> for contact grid<th> for column headers<td> for data cells
Print Button:
ARIA label implicit from button text
Screen Reader Support:
alt attributes describe purposeNote: 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:
qrCode1Url, qrCode2Url, qrCode3UrlAt least one URL must be provided
Test QR API endpoint:
curl http://localhost:4000/api/qr?text=https://example.com&size=100 --output test-qr.png\n Open test-qr.png to verify QR code generated
Check browser console:
/api/qr?text=... requestsIf CORS error, check nginx CORS headers
Verify API base URL:
.env file: VITE_API_URL=http://localhost:4000.envSymptoms: - 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:
This ensures table borders and support circles print
Adjust page margins:
Too large margins can cut off content
Verify print CSS:
If other elements visible, print CSS not applying
Check browser zoom:
Print preview at wrong zoom can cause layout issues
Try different browser:
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:
\"Fit to page\" shrinks content to fit, making circles smaller
Adjust circle size in code:
WalkSheetPage.tsx.support-circle dimensions: .support-circle {\n width: 20px; /* Was 16px */\n height: 20px; /* Was 16px */\n font-size: 11px; /* Was 9px */\n}\nPrint again to test
Increase row height:
<td style={{ height: 32 }}> </td> {/* Was 28px */}\nSymptoms: - 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:
This gives more vertical space for content
Reduce font sizes:
@media print {\n .walk-sheet-print { font-size: 10px !important; } /* Was 11px */\n .walk-sheet-print table { font-size: 8px !important; } /* Was 9px */\n}\nSmaller fonts = more content fits on page
Reduce number of rows:
If footer consistently cut off, reduce rows from 12 to 10:
const rows = Array.from({ length: 10 }, (_, i) => i); // Was 12\n Remove footer (temporary):
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:
Success message should appear
Check browser console:
If \"Failed to load settings\", API request failed
Check Network tab:
GET /api/map/settings request{\n \"walkSheetTitle\": \"...\",\n \"walkSheetSubtitle\": \"...\"\n}\nIf fields null/missing, settings not saved in database
Check database:
docker compose exec api npx prisma studio\n# Navigate to MapSettings table\n# Verify walkSheetTitle and walkSheetSubtitle columns populated\n 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":"/campaigns, /map, /shifts, /p/:slug, /media)#0d1b2a background)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:
Grid.useBreakpoint()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:
Login button (when not authenticated)
Content Area
Dark theme styling
Footer
Public pages are optimized for mobile:
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.
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":"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
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:
Government-level aware representative discovery:
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)
Campaign-specific response display:
/responses/:campaignIdShareButtons component for campaign promotion:
/campaigns/:id)Representative selection happens implicitly (no checkboxes):
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":"loading=true, fetch campaign by IDsetCampaign(), setLoading(false)setPostalCode() updates inputsetRepsLoading(true), fetch representativessetRepresentatives(), setRepsLoading(false), auto-advance to step 3setCustomEmailBody() if editing allowedsetSendingTo(rep.email), post to APIsetSendingTo(null), show success message, increment counterGET /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
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":"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')
The hero section provides visual branding and context:
linear-gradient(135deg, #667eea 0%, #764ba2 100%)Postal code lookup interface for representative discovery:
Highlighted campaign with premium styling:
2px solid #f39c12 with glow shadowResponsive grid layout for all campaigns:
ShareButtons component integration:
Graceful handling of no-data scenarios:
/campaigns/api/settings/api/public/campaigns/api/public/representatives/lookup?postalCode=X/campaigns/:id detail pageimport 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":"loading=true, fetch campaigns and settings in parallelsetCampaigns(), setSettings(), setLoading(false)setPostalCode() updates statesetRepsLoading(true), fetch repssetRepresentatives(), setRepsLoading(false)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":"objectFit: 'cover' prevents layout shiftRepresentative 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
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":"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:
<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":"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)
Minimal header to maximize map space:
#0d1b2a)Visual support level indication:
#52c41a)#95de64)#fadb14)#ff7a45)#f5222d)#8c8c8c)#d9d9d9)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":"/mapmoveend event triggers after 800ms debounceimport 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='© <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":"key prop to prevent unnecessary re-rendersCauses: 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":"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)
<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
<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
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":"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:
<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":"preload=\"metadata\" for faster initial renderFile 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
Navigation and campaign identification:
/campaigns/:campaignId)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
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
Ant Design Pagination component:
isVerified=false/api/public/responses/:id/upvoteimport 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":"loading=true, fetch campaign + responses + statssetCampaign(), setResponses(), setStats(), setTotal(), loading=falsesetSortBy(), setPage(1), refetch responsessetGovernmentLevel(), setPage(1), refetch responsessetPage(), refetch responses (keep sort/filter)responses array, API callsetSubmitModalVisible(true), open formsetSubmitModalVisible(false), refetch responsesGET /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\".
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
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":"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
Prominent call-to-action header:
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":"/shiftsimport 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":"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":"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":"/volunteer/*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).
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:
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
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:
Frontend displays route as blue polyline connecting locations.
"},{"location":"v2/frontend/pages/volunteer/#visit-outcomes","title":"Visit Outcomes","text":"Available outcomes in recording form:
Volunteer pages are optimized for mobile:
GPS canvass map optimizations:
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:
<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":"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:
<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='© <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":"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:
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":"/volunteer/canvass/:cutIdimport 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":"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":"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
<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":"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":"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:
Before you begin, ensure you have:
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:
If you encounter problems during setup, check our troubleshooting guides:
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.
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
Open your browser and navigate to:
Login with: - Email: admin@example.com - Password: Admin123!
Change Default Credentials
Immediately change the default admin password:
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:
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:
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":"Pangolin tunnel integration
Enhanced Existing Features:
Listmonk newsletter sync
Improved Admin Experience:
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":"Impact: High (business-critical data)
Authentication Disruption
Impact: High (blocks all access)
Email Delivery Failure
Impact: Medium (cacheable data)
Location Geocoding
Impact: Medium (can be re-geocoded)
Shift Signups
Impact: Low (public-facing only)
Custom Settings
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 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 Verification:
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":"Feature Parity - Feature comparison matrix
V2 Docs:
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":".env file)git checkout v2)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":"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 Set Up V2 Environment
git checkout v2\ncp .env.example .env\n# Edit .env with V2 configuration\n Start V2 Services (parallel to V1)
docker compose up -d v2-postgres redis\ndocker compose exec api npx prisma migrate deploy\n Transform V1 Data for V2
# Run transformation scripts\nnode scripts/transform-users.js\nnode scripts/transform-campaigns.js\nnode scripts/transform-locations.js\n Import into V2 Database
# Import transformed data\ndocker compose exec api node scripts/import-data.js\n Validate Data Integrity
# Compare record counts\ndocker compose exec api node scripts/validate-migration.js\n Check admin permissions
Performance Testing
Enable Maintenance Mode (V1)
# Stop V1 services\ndocker compose -f docker-compose.v1.yml down\n Start V2 Services
# Start all V2 services\ndocker compose up -d\n Update DNS/Proxy
cmlite.org to V2 nginxSmoke Tests
Monitor for Issues
# Watch logs for errors\ndocker compose logs -f api admin\n\n# Check metrics\nopen http://localhost:3001 # Grafana\n Announce Migration Complete
After successful migration, complete these tasks:
"},{"location":"v2/migration/#immediate-day-1","title":"Immediate (Day 1)","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":"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":"Ready to begin migration?
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:
/api/*){ success, data, pagination } structure)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":"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).
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 NocoDBprisma.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.
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/)
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
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)
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
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
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)
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)
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
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.).
All CRUD operations via API/Admin GUI
Embedded EJS Views
All UI is React SPA
Session-Based Multi-Tenancy
MkDocs export (Jinja2 templates)
Email Templates System
HTML + plain text variants
Media Library
Bulk operations
Volunteer Canvassing
Admin dashboard with leaderboards
Data Quality Dashboard
Bulk re-geocoding tools
Comprehensive Monitoring
cm_* metrics)Docker healthchecks
NAR 2025 Import
Province/city/postal filtering
Pangolin Tunnel
V1: Single SMTP config \u2192 V2: Test mode + Listmonk integration
Response Wall
V1: Simple submission form \u2192 V2: Moderation + upvoting + verification
Geocoding
V2 adds: ArcGIS, Photon, Mapbox, Google, OpenCage
User Roles
admin, user \u2192 V2: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMPV1: No requirements \u2192 V2: 12+ chars, uppercase, lowercase, digit (Zod schema)
Rate Limiting
V1: None \u2192 V2: Auth endpoints 10/min per IP, canvass visits 30/min
Refresh Token Rotation
V1: Static sessions \u2192 V2: Atomic token rotation (prevents replay attacks)
User Enumeration Prevention
V2: Login returns 401 for both invalid email and password (V1 returned different errors)
Redis Authentication
V1: No password \u2192 V2: Required REDIS_PASSWORD
Encryption Key
V2: Separate ENCRYPTION_KEY for sensitive DB fields (different from JWT secrets)
Input Sanitization
V2: HTML escaping for user content (responses, emails, templates)
Path Traversal Protection
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":"Connection pooling reduces latency
Query Optimization
Indexed foreign keys, unique constraints
Caching Strategy
Prisma query result caching
Dual API Architecture
Prevents main API blocking on large file uploads
Monitoring
http_request_duration_seconds histogramMigration 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:
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:
docker compose up -d v2-postgres redis api)npx prisma migrate deploy)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":"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 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 Test V2 on Staging (use procedure above)
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 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 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/\nStop V1 Completely
docker compose -f docker-compose.v1.yml down\n Start V2 Database
docker compose up -d v2-postgres redis\ndocker compose exec api npx prisma migrate deploy\n Import Data
node scripts/import-v2-data.js | tee migration.log\n Validate Import
node scripts/validate-migration.js\n 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 Smoke Test
./scripts/test-v2-workflows.sh\n Update DNS/Tunnel
Watch Logs
docker compose logs -f api admin\n Monitor Metrics
Test User Logins
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 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":"Review Migration Logs
cat migration.log | grep ERROR\n Identify Root Cause
Application bugs?
Fix Issues on Staging
Validate thoroughly
Reschedule Migration
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":"After successful migration:
Email Configuration
Train Administrators
Volunteer Canvassing
Enable New Features
Media Library
Set Up Monitoring
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
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.
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":"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:
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":"Vote on features at: https://github.com/changemaker-lite/v2/discussions
"},{"location":"v2/migration/feature-parity/#related-documentation","title":"Related Documentation","text":"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:
Frequently asked questions:
Container and orchestration problems:
PostgreSQL and Prisma problems:
Login and permission problems:
Email delivery problems:
Address geocoding problems:
Observability and metrics problems:
Speed and efficiency improvements:
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
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)
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
# 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:
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:
Role-based access control (RBAC) with 5 roles:
Role Level PermissionsSUPER_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":"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":"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":"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":"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":"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:
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":"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":"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":"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":"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":"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":"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":"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":"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":"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 \u274cSolution 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":"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":"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":"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":"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":"Solution 1: Check spam folder
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":"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":"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":"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":"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":"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":"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:
localStorage.clear()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":"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":"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":"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":"openssl rand -hex 32Severity: \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":"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):
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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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:
Security Note
Never use CORS_ORIGINS=* in production with credentials enabled.
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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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:
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":"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":"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":"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":"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":"\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
\u274c Configuration errors - Your setup is wrong
\u274c Environment issues - Your system is incompatible
\u274c User errors - Misunderstanding how to use
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:
# 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":"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":"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":"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":"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":"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":"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":"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":"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":"prisma migrate dev in devprisma migrate deploy in prodSeverity: \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":"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":"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":"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":"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":"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":"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
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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"# 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":"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":"docker compose downdocker compose ps firstSeverity: \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":"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":"docker compose up./ in docker-compose.ymlSeverity: \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":"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":"config/env.tsSeverity: \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":"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":"depends_on with condition: service_healthySeverity: \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":"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":"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":"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":"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":"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":"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":"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":"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":"ports: in docker-compose.ymlSolution 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":"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":"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":"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":"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":"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":"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":"./Severity: \ud83d\udd34 Critical
"},{"location":"v2/troubleshooting/docker-issues/#symptoms_12","title":"Symptoms","text":"Data disappears after docker compose down:
docker compose down -v removes volumesSolution 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":"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":"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":"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":"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":"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":"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":"# 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":"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:
System notifications
Newsletter Emails (Listmonk)
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":"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":"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):
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":"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.
Solution 3: Check account status
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":"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":"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":"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":"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:
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":"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":"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":"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":"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 < $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":"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":"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:
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":"Solution 1: Check spam folder
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":"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
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":"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":"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":"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":"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":"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":"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":"# 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":"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:
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):
Supported browsers:
Mobile browsers:
Required features:
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:
Via Admin UI (recommended):
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:
Changemaker Lite doesn't include HTTPS natively. Use one of these options:
Option 1: Pangolin Tunnel (Recommended)
Built-in integration:
/app/pangolinSee Pangolin Integration.
Option 2: Cloudflare Tunnel
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):
/app/usersVia 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 onlyPermission 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 \u274cLogin redirects:
/app (admin dashboard)/volunteer (volunteer portal)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":"/app/influence/campaigns/app/influence/campaigns/campaigns (public)/app/influence/campaignsVia Email Queue Page:
/app/influence/email-queue/app/influence/responsesResponse verification workflow:
Via CSV:
/app/map/locationsaddress,city,province,postalCode,notes\n123 Main St,Toronto,ON,M5H 2N2,Corner house\n456 Oak Ave,Toronto,ON,M5H 2N3,Blue door\nVia NAR (Canadian Electoral Data):
/data directory (mapped volume)/app/map/locationsSee NAR Import Guide.
"},{"location":"v2/troubleshooting/faq/#how-to-create-cuts","title":"How to Create Cuts?","text":"Via Map Drawing:
/app/map/cutsVia GeoJSON Import:
{\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/app/map/cuts/app/map/shiftsManage signups:
For volunteers:
For admins (monitoring):
/app/canvass/dashboardSee 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:
api/prisma/schema.prismaapi/prisma/migrations/30+ models (User, Campaign, Location, etc.)
Drizzle - Media API (Fastify)
api/src/modules/media/db/schema.tsmedia_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:
Endpoints: /api/*
Fastify Media API (Microservice)
/api/media/*Shared:
Frontend:
JWT-based authentication:
Tokens:
Used: All authenticated requests
Refresh Token
Flow:
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:
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:
PostgreSQL:
Typical sizes (after 1 year):
Storage requirements:
Optimization:
At rest:
In transit:
Recommendations:
Enforced policy:
Valid examples:
SecurePass123!MyPassword99Admin12345678Invalid:
short (too short)nouppercase123 (no uppercase)NOLOWERCASE123 (no lowercase)NoDigitsHere (no digit)Storage:
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:
/var/lib/postgresql/data/log/ (if logging enabled)/var/log/nginx/access.log, /var/log/nginx/error.logLog levels:
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:
Before creating issue:
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:
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":"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):
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":"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":"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":"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":"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":"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":"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 planSolution 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":"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":"Solution 1: Disable ad blocker
*.openstreetmap.orgSolution 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='© <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":"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":"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":"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":"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":"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":"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
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":"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":"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
43.651234, -79.381234Severity: \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":"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":"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":"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:
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":"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":"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":"Severity: \ud83d\udfe1 Medium
"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_16","title":"Symptoms","text":"Many locations with null latitude/longitude.
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":"# 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":"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:
12 custom cm_* Prometheus metrics:
cm_api_uptime_seconds - API uptimecm_database_uptime_seconds - Database uptimecm_email_queue_size - Email queue depthcm_geocoding_queue_size - Geocoding queue depthcm_users_total - Total userscm_campaigns_total - Total campaignscm_locations_total - Total locationscm_geocoded_locations_total - Geocoded locationscm_active_canvass_sessions - Active sessionscm_external_service_up - Service health (0/1)cm_listmonk_subscribers_total - Listmonk subscriberscm_media_videos_total - Total videosPlus standard HTTP metrics: - http_request_duration_seconds - http_requests_total
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":"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":"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":"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":"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
http://prometheus:9090Solution 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:
configs/grafana/dashboards/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":"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":"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
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":"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):
for: durationSolution 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":"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":"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):
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":"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":"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":"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":"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":"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":"# 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":"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":"Target performance:
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:
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:
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:
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":"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":"Explore dashboard
User Role Redirection
/app/dashboard/volunteer/dashboard/app/influence/campaignsSave campaign
Design Email Template
Preview template
Launch Campaign
/app/map/locationsImport data
Geocode Addresses
Review quality metrics
Create Geographic Cuts
/app/map/cuts/volunteer/assignmentsView upcoming shifts
Start Canvassing
Visit locations
Record Visits
Submit
End Session
/data directoryEnsure Address + Location files present
Import via Admin
/app/map/locationsStart import
Review Import
Set to published
Share URL
/campaigns/:idEmbed in website
Monitor Engagement
LISTMONK_SYNC_ENABLED=trueRestart services
Initialize Sync
/app/services/listmonkClick \"Sync Participants\"
Manage Lists
Generate API key
Configure Tunnel
/app/services/pangolinDeploy Newt container
Test Public Access
/app/pagesEnter title and slug
Design Page
Save (Ctrl+S)
Publish
/p/:slugBest on mobile devices:
Best on desktop:
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):
admin@example.comAdmin123!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:
Active canvass sessions
Recent Activity Feed
Canvass visits
Quick Actions
View email queue
System Health
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:
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:
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":"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:
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:
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:
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:
When deletion is appropriate:
Data handling on deletion:
To see recent login activity:
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:
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:
Required fields:
Basic Information:
protect-our-climate/campaigns/protect-our-climateEmail Configuration:
{{USER_NAME}}, {{REP_NAME}}{{USER_NAME}}, {{USER_EMAIL}}, {{REP_NAME}}, {{REP_EMAIL}}, {{USER_MESSAGE}}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":"Toggle to launch/pause campaign
Featured
Limit to 2-3 featured campaigns
Has Response Wall
Responses require admin approval (unless auto_approve_responses)
Collect Phone Numbers
Numbers stored for admin use
Track Calls
Recommended for public campaigns
Auto Approve Responses
Only use for trusted campaigns
Allow Anonymous
Good for privacy-sensitive topics
Custom Recipients
Use for non-government campaigns
Show Progress Bar
email_goal fieldDisable After Date
disable_date fieldEnable Comments
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:
{{USER_NAME}} to personalizeExample 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:
{{USER_NAME}} \u2014 Citizen's full name{{USER_EMAIL}} \u2014 Citizen's email address{{USER_PHONE}} \u2014 Citizen's phone (if collected){{REP_NAME}} \u2014 Representative's name{{REP_EMAIL}} \u2014 Representative's email{{REP_TITLE}} \u2014 Representative's title (MP, MPP, Councillor){{USER_MESSAGE}} \u2014 Custom message from citizen (optional field)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:
The campaign is now live at /campaigns/[slug].
Promoting your campaign:
https://yourdomain.org/campaigns/protect-our-climateScreenshot 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:
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:
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:
Response filters:
Response table columns:
Screenshot placeholder: Responses table with filter controls and status badges
To review a response:
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:
To import locations:
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:
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.
Geocoding converts addresses to latitude/longitude coordinates for map display.
Automatic geocoding:
Manual geocoding:
Geocoding providers (tried in order):
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:
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:
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:
To delete locations:
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:
OR:
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:
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:
The signups drawer shows:
Signup sources:
/shifts page (creates TEMP users)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:
{{NAME}}, {{SHIFT_TITLE}}, {{SHIFT_START}}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:
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:
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:
Available settings:
Branding:
Email Configuration:
Representative API:
https://represent.opennorth.caFeature 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:
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:
Available toggles:
ENABLE_MEDIA_FEATURES
ENABLE_LISTMONK_SYNC
ALLOW_PUBLIC_SHIFT_SIGNUP
/shiftsREQUIRE_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.
Changemaker Lite uses email templates for system-generated emails:
System templates:
Custom templates:
To edit an email template:
{{USER_NAME}}, {{SHIFT_TITLE}})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:
{{USER_NAME}} \u2014 User's full name{{USER_EMAIL}} \u2014 User's email addressShift variables:
{{SHIFT_TITLE}} \u2014 Shift name{{SHIFT_START}} \u2014 Start date/time{{SHIFT_END}} \u2014 End date/time{{SHIFT_LOCATION}} \u2014 Meeting location{{SHIFT_CUT}} \u2014 Cut nameCampaign variables:
{{CAMPAIGN_TITLE}} \u2014 Campaign name{{CAMPAIGN_URL}} \u2014 Link to campaign pageSystem variables:
{{SITE_NAME}} \u2014 Your organization name{{SITE_URL}} \u2014 Website URLScreenshot 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:
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:
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:
Shared videos appear on the public media gallery at /media.
To unshare videos:
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:
To 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:
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:
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:
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:
The observability dashboard has three tabs:
"},{"location":"v2/user-guides/admin-guide/#metrics-tab","title":"Metrics Tab","text":"cm_* Prometheus metricsScreenshot 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:
Symptoms: Emails stuck in \"Waiting\" status
Solutions:
EMAIL_TEST_MODE=true, emails go to MailHog (not real recipients)Symptoms: Error during CSV upload
Solutions:
Symptoms: Addresses remain ungeocoded after import
Solutions:
Symptoms: Blank map or loading spinner
Solutions:
Symptoms: Campaign visible in admin but not on /campaigns
Solutions:
/campaigns/[slug]Symptoms: Error when volunteer clicks \"Start Canvassing\"
Solutions:
Documentation:
/docs/v2/features/ (detailed feature guides)/docs/v2/api/ (API endpoint documentation)/docs/v2/user-guides/ (this guide and others)/docs/v2/deployment/ (server setup, Docker, backups)Support channels:
Before asking for help:
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":"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:
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:
Example: \"Urge your MP to support climate action\"
Provincial campaigns: Target MPPs/MLAs (provincial legislators)
Example: \"Tell your MPP to fund public transit\"
Municipal campaigns: Target city councillors, mayors
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:
To create a new campaign:
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:
What it is: URL-friendly identifier, auto-generated from title
Format: lowercase, hyphens for spaces, no special characters
Examples:
protect-our-forestsfund-public-transitUsed 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).
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:
What it is: Subject line for emails citizens send to representatives
Best practices:
Variables available:
{{USER_NAME}} \u2014 Sender's name{{REP_NAME}} \u2014 Representative's name{{REP_TITLE}} \u2014 Representative's title (MP, MPP, Councillor)Examples:
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:
{{USER_NAME}} \u2014 Citizen's full name{{USER_EMAIL}} \u2014 Citizen's email{{USER_PHONE}} \u2014 Citizen's phone (if collected){{REP_NAME}} \u2014 Representative's name{{REP_EMAIL}} \u2014 Representative's email{{REP_TITLE}} \u2014 Representative's title (MP, MPP, Councillor){{USER_MESSAGE}} \u2014 Citizen's custom message (optional field on form)Tips:
{{USER_MESSAGE}} at the end so citizens can add personal storiesWhat 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:
disable_after_date)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:
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.
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:
What it does: Sends verification email before recording email send
When to enable:
When to disable:
How it works:
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:
What it does: Override representative lookup and send to specific email addresses
When to enable:
How to use:
custom_recipient_emails fieldcustom_recipient_names fieldExample:
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:
email_goal field (e.g., 1000)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:
disable_date field (date picker)Example:
Legislative vote is March 15. Set disable_date to March 15, 2024. Campaign automatically closes that day.
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:
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:
The test email uses sample data for variables.
"},{"location":"v2/user-guides/campaign-manager-guide/#publishing","title":"Publishing","text":"To publish:
The campaign is now live at /campaigns/[slug].
Promotion channels:
https://yoursite.org/campaigns/protect-our-forestsSample 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:
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:
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:
Metrics:
Response rate:
Response rate = Responses / Emails sent\n Typical response rates:
To monitor the queue:
Key metrics:
Critical: > 200 (likely queue backup)
Active: Emails currently being sent
Normal: 1-5 (concurrent workers)
Completed (last 24 hours): Successfully sent
Failed: Delivery failures
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:
Moderation decisions:
Approve if:
Reject if:
Delete if:
To review in detail:
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:
If you accidentally reject a good response:
If inappropriate content slips through:
If user complains about rejection:
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":"Test different versions of your campaign to find what works best.
Elements to test:
Urgent vs neutral tone
Call to action
Button size
Campaign description
Include statistics vs stories
Feature flags
How to A/B test:
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":"Upvotes signal which responses resonate most with your community.
Tactics:
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:
CSV includes:
Use cases:
Response wall export:
CSV includes:
Use cases:
Symptoms: Few people sending emails despite high traffic
Diagnostic questions:
Check representative cache (Influence > Representatives)
Is the form too complex?
Disable verification
Is the call to action clear?
Add urgency or social proof
Is trust an issue?
Solutions:
Symptoms: Emails being sent but few response wall submissions
Possible causes:
Show recent responses below email form
Friction too high
Long approval delay \u2192 people think it didn't work
No examples/social proof
Solutions:
Symptoms: Emails remain in PENDING status for > 1 hour
Diagnostic steps:
Common causes:
Restart api service
SMTP credentials wrong
Send test email to verify
SMTP server rejecting
Contact email service provider
Network issue
Emergency solution:
Symptoms: Many emails with FAILED status
Check error messages:
Use custom recipients as workaround
\"SMTP authentication failed\"
Update in Settings > Email Configuration
\"Connection timeout\"
Contact system administrator
\"Mailbox full\"
Nothing you can do (contact representative's office)
\"Spam filter rejected\"
Solutions:
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)
/p/[slug]2. Email Templates (/app/email-templates)
3. Media Library (/app/media/library, if enabled)
Landing pages are custom web pages published at /p/[slug]. Use them for:
To create a landing page:
about-us \u2192 /p/about-us)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:
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:
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:
To change background:
To adjust spacing:
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:
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:
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:
What it is: Centered section with headline and button
How to use:
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:
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:
<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:
{{SITE_NAME}} \u2014 Organization name (from settings){{SITE_URL}} \u2014 Website URL{{USER_NAME}} \u2014 Logged-in user's name (if authenticated)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":"Draft \u2192 Published:
Draft pages:
Published pages:
/p/[slug]To preview a page before publishing:
/p/[slug]?preview=trueOR:
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:
To unpublish a page:
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:
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:
docs/overrides/ directoryScreenshot 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:
Each template has three parts:
1. Subject Line
{{USER_NAME}}, {{SHIFT_TITLE}})2. HTML Body
3. Plain Text Body
To edit a template:
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:
{{USER_NAME}} \u2014 User's full name{{USER_EMAIL}} \u2014 User's email addressShift variables:
{{SHIFT_TITLE}} \u2014 Shift name{{SHIFT_START}} \u2014 Start date/time (formatted){{SHIFT_END}} \u2014 End date/time (formatted){{SHIFT_LOCATION}} \u2014 Meeting location{{SHIFT_CUT}} \u2014 Cut nameCampaign variables:
{{CAMPAIGN_TITLE}} \u2014 Campaign name{{CAMPAIGN_URL}} \u2014 Link to campaign pageSystem variables:
{{SITE_NAME}} \u2014 Organization name (from settings){{SITE_URL}} \u2014 Website URL{{RESET_LINK}} \u2014 Password reset link (password reset emails only){{VERIFICATION_LINK}} \u2014 Verification link (response verification emails only)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:
To test a template:
The test email uses sample data for variables:
{{USER_NAME}} \u2192 \"Test User\"{{SHIFT_TITLE}} \u2192 \"Sample Shift\"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:
/mediaUse cases:
To upload a video:
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:
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:
Editable fields:
Non-editable fields (auto-extracted):
To delete a video:
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:
To 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:
To share videos publicly:
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:
Table shows:
To unshare videos:
Videos are removed from public gallery but remain in library.
To change category:
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:
Mobile traffic is 50-70% of web traffic. Optimize for mobile:
Responsive design:
Touch targets:
Load time:
Readability:
On-page SEO:
Technical SEO:
/p/about-us, not /p/page?id=123)WCAG 2.1 Level AA compliance:
Perceivable:
Operable:
Understandable:
Robust:
<nav>, <main>, <article>, not just <div>)Issue: Page editor won't load
Solutions:
Issue: Changes not saving
Solutions:
Issue: Page looks different when published
Causes:
Solutions:
Issue: Variables not replacing
Symptoms: Email shows {{USER_NAME}} instead of actual name
Causes:
Solutions:
Issue: Email looks broken in Outlook
Causes: Outlook uses Microsoft Word rendering engine (poor CSS support)
Solutions:
Issue: Video won't upload
Solutions:
Issue: Metadata extraction failed
Symptoms: Duration shows \"Unknown\", quality shows \"N/A\"
Causes:
Solutions:
Issue: Video won't play on public gallery
Causes:
Solutions:
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":"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:
Building-level data (recommended):
unitCount field indicates multi-unit buildingsunitCount: 24 (apartment building)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).
1. CSV Import \u2014 Your own data
2. NAR Import \u2014 Canadian electoral data
3. Manual Entry \u2014 Individual addresses
Required columns:
address \u2014 Full street address (e.g., \"123 Main St\")city \u2014 City name (e.g., \"Ottawa\")province \u2014 Province/state code (e.g., \"ON\", \"BC\")postalCode \u2014 Postal code (e.g., \"K1A 0B1\")Optional columns:
latitude \u2014 Pre-geocoded latitude (decimal degrees)longitude \u2014 Pre-geocoded longitude (decimal degrees)buildingType \u2014 RESIDENTIAL, APARTMENT, or BUSINESSunitCount \u2014 Number of units (integer, default: 1)federalDistrict \u2014 Electoral district namenotes \u2014 Internal notesCSV 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:
Excel to CSV:
To import locations:
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:
Import limits:
Issue: \"Invalid CSV format\"
Causes:
Solutions:
Issue: \"Missing required field\"
Causes:
Solutions:
Issue: \"Geocoding failed for X addresses\"
Causes:
Solutions:
NAR (National Address Register) is Elections Canada's official database of all residential addresses in Canada. It includes:
Advantages:
Disadvantages:
NAR data must be obtained from Elections Canada:
Files needed:
Address_[province]_part_[X].csv \u2014 Civic addressesLocation_[province].csv \u2014 Geocoded coordinatesSystem administrator places files in /data directory on server.
To import NAR data:
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:
LOC_GUID (internal Elections Canada ID)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:
address \u2014 From Address file: CIVIC_NO + OFFICIAL_STREET_NAME + STREET_TYPE + STREET_DIRECTIONcity \u2014 From Address file: MUNICIPALITY_NAMEprovince \u2014 From province codepostalCode \u2014 From Address file: POSTAL_CODElatitude \u2014 From Location file: BG_LATITUDE (converted to WGS84)longitude \u2014 From Location file: BG_LONGITUDE (converted to WGS84)federalDistrict \u2014 From Location file: FED_NUM (district number) + name lookupbuildingUse \u2014 From Address file: BUILDING_USE (RESIDENTIAL, COMMERCIAL, INSTITUTIONAL)A cut is a geographic area used to organize canvassing. Cuts are polygons drawn on a map.
Common cut types:
Why use cuts?
Size:
Boundaries:
Naming:
Colors:
To create a cut:
Screenshot placeholder: Cut drawing interface showing map with polygon being drawn
Drawing tips:
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:
Re-assignment:
To edit a cut:
Note: You cannot edit the polygon shape after creation. To change boundaries, delete the cut and redraw.
To delete a cut:
What happens to locations?
To view all 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:
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:
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:
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:
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:
Changemaker Lite tries multiple geocoding providers in order:
How it works:
API keys (optional, configured by admin):
MAPBOX_API_KEYGOOGLE_MAPS_API_KEYLOCATIONIQ_API_KEYWithout 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:
To review geocoding 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
Common issues:
Strategy 2: Re-geocode with Better Provider
Strategy 3: Manually Place Locations
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:
To create a shift:
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:
The signups drawer shows:
Capacity gauge:
Signup list:
Signup sources:
/shifts page):Sends confirmation email
Admin-added:
Select existing users or create new
Volunteer 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:
Upgrading TEMP users to USER:
After a TEMP user attends their first shift:
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:
{{NAME}}, {{SHIFT_TITLE}}, {{SHIFT_START}}, {{MEETING_LOCATION}}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:
To configure walk sheet defaults:
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:
OR:
Walk sheet contents:
Page 1:
/volunteer/canvass/[cutId])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:
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:
Volunteers record:
After the canvass:
To view overall canvass progress:
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:
Cut detail view shows:
Export cut data:
To view active canvass sessions:
Each active session shows:
Warning signs:
Actions:
To understand canvassing results:
Outcome categories:
Interpreting outcomes:
High NOT_HOME rate (> 60%):
High REFUSED rate (> 20%):
Low SPOKE_WITH rate (< 20%):
High WRONG_ADDRESS (> 5%):
To understand voter sentiment:
Support level breakdown:
Targeting strategy:
For GOTV:
For persuasion:
For opposition:
To evaluate volunteer effectiveness:
Metrics:
Identifying top performers:
Coaching opportunities:
Issue: Many locations ungeocoded after import
Solutions:
Issue: Locations geocoded to wrong area
Symptoms: Locations appear far from where they should be
Solutions:
Issue: Locations not assigning to cut
Symptoms: Locations inside polygon not assigned after cut creation
Solutions:
Issue: Overlapping cuts
Symptoms: Some locations assigned to wrong cut
Cause: Multiple cuts cover the same area
Solution:
Issue: Volunteer cannot start canvass session
Symptoms: \"No active shift found\" error
Solutions:
Issue: Shift signups not appearing
Symptoms: Public signup form doesn't show shift
Solutions:
Issue: Walking route not updating
Symptoms: Route doesn't change after completing visits
Solutions:
Issue: Visit won't save
Symptoms: Volunteer reports \"Save Visit\" doesn't work
Solutions:
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":"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:
https://app.yourorg.org)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:
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:
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:
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:
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":"If your organizer gave you a printed walk sheet:
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:
Accuracy circle shows GPS precision
Locations to visit (house icons)
Blue house: Not home
Walking route (purple line)
Follow the line for efficient canvassing
Cut boundary (colored polygon)
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:
To follow the route:
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:
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:
When to use:
What happens:
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:
When to use:
What happens:
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":"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:
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:
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:
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:
On Android:
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:
Android: Settings > Location > Google Location Accuracy > ON
Ensure clear sky view
Trees and structures reduce accuracy
Wait for signal
Blue circle will shrink as accuracy improves
Keep phone unlocked
Consider increasing screen timeout
Use Wi-Fi
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:
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:
When you're done canvassing:
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:
To view your canvassing history:
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:
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:
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:
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:
Minimum requirements:
Recommended:
Data usage:
Before you go:
While canvassing:
If you feel unsafe:
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:
Symptoms: Purple line doesn't change after completing visits
Solutions:
Symptoms: \"Save Visit\" button doesn't work or shows error
Solutions:
Symptoms: Visit recording form stays open after saving
Solutions:
If you have technical issues during canvassing:
If you have questions about canvassing technique:
If you have account or scheduling issues:
Last updated: February 2026 (V2 complete)
Need help? Contact your organizer or visit the documentation at /docs.